写点什么

HTTP 认证

作者:周杰伦本人
  • 2022 年 7 月 08 日
  • 本文字数:6663 字

    阅读完需:约 22 分钟

HTTP 认证

通过 HTTP 请求头来提供认证信息,而不是通过表单登录。有 HTTP Basic authentication HTTP Digest authentication

HTTP Basic authentication

HTTP Basic authentication 将用户的登录用户名 密码经过 Base64 编码之后,放在请求头的 Authorization 字段中,从而完成用户身份的认证。


  1. 客户端发送请求

  2. 服务端收到请求后,发现用户还没有认证,返回状态码 401 WWW-Authenticate 响应头则定义了使用何种验证方式去完成身份认证,最简单最常见的是 HTTP 基本认证(Basic),还有 Bearer(Oauth2.0 认证),Digest(HTTP 摘要认证)

  3. 客户端收到服务端响应后,将用户名密码使用 Base64 编码后,放在请求头中,再次发送请求

  4. 服务端解析 Authorization 字段,完成用户身份的校验,最后将资源返回给客户端。


这种认证方式很少使用,因为有安全问题,HTTP 基本认证没有对传输的凭证信息进行加密,仅仅只是进行了 Base64 编码。


通过 httpBasic()方法即可开启 HTTP 基本认证。


Security 实现 HTTP 基本认证分为两部分:


  1. 对未认证的请求发出质询

  2. 解析携带认证信息的请求。

对未认证的请求发出质询

httpBasic()方法开启了 HTTP 基本认证的配置,具体配置通过 HttpBasicConfigurer 完成。HttpBasicConfigurer 的 init 方法中调用 registerDefaultEntryPoint 完成失败请求处理类 AuthenticationEntryPoint 的配置


private void registerDefaultEntryPoint(B http, RequestMatcher preferredMatcher) {   ExceptionHandlingConfigurer<B> exceptionHandling = http         .getConfigurer(ExceptionHandlingConfigurer.class);   if (exceptionHandling == null) {      return;   }   exceptionHandling.defaultAuthenticationEntryPointFor(         postProcess(this.authenticationEntryPoint), preferredMatcher);}
复制代码


对 exceptionHandling 配置的最终目的是配置异常过滤器 ExceptionTranslationFilter,authenticationEntryPoint 是代理对象,在 HttpBasicConfigurer 构造方法中创建,具体代理的是 BasicAuthenticationEntryPoint,


public void commence(HttpServletRequest request, HttpServletResponse response,      AuthenticationException authException) throws IOException {   response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\"");   response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());}
复制代码


响应头添加 WWW-Authenticate 字段,然后发送错误响应,响应码 401


总结:未认证的请求,在经过 Spring Security 过滤器链时会抛出异常,异常在 ExceptionTranslationFilter 过滤器链中调用 BasicAuthenticationEntryPoint 的 commence 方法进行处理。

解析携带认证信息的请求

HttpBasicConfigurer 的 configure 方法中,向 Spring Security 过滤器链中添加了 BasicAuthenticationFilter 过滤器


protected void doFilterInternal(HttpServletRequest request,      HttpServletResponse response, FilterChain chain)            throws IOException, ServletException {   final boolean debug = this.logger.isDebugEnabled();   try {      UsernamePasswordAuthenticationToken authRequest = authenticationConverter.convert(request);      if (authRequest == null) {         chain.doFilter(request, response);         return;      }
String username = authRequest.getName();
if (debug) { this.logger .debug("Basic Authentication Authorization header found for user '" + username + "'"); }
if (authenticationIsRequired(username)) { Authentication authResult = this.authenticationManager .authenticate(authRequest);
if (debug) { this.logger.debug("Authentication success: " + authResult); }
SecurityContextHolder.getContext().setAuthentication(authResult);
this.rememberMeServices.loginSuccess(request, response, authResult);
onSuccessfulAuthentication(request, response, authResult); }
} catch (AuthenticationException failed) { SecurityContextHolder.clearContext();
if (debug) { this.logger.debug("Authentication request for failed!", failed); }
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, failed);
if (this.ignoreFailure) { chain.doFilter(request, response); } else { this.authenticationEntryPoint.commence(request, response, failed); }
return; }
chain.doFilter(request, response);}
复制代码


  1. 调用 authenticationConverter.convert 方法,对请求头中的 Authorization 字段进行解析,经过 Base64 解码后的用户名密码用冒号隔开,然后构造出 UsernamePasswordAuthenticationToken 的实例 authRequest

  2. 如果 authRequest 为 null,说明请求头中没有包含认证信息,直接指向接下来的过滤器,最终通过 ExceptionTranslationFilter 过滤器链中调用 BasicAuthenticationEntryPoint 的 commence 方法进行处理。如果不为 null,说明请求携带了认证信息,那么对认证信息进行校验。

  3. 具体校验为从 authRequest 中提取出用户名,然后调用 authenticationIsRequired 方法判断是否需要认证,不需要认证执行下一个过滤器,需要认证则进行认证。authenticationIsRequired 是从 SecurityContextHolder 中渠道当前登录对象,判断是否已经登录过。

  4. 调用 authenticationManager.authenticate(authRequest)方法完成用户认证,将用户信息存入 SecurityContextHolder 中

HTTP Digest authentication

需要自定义配置


@Configurationpublic 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());    }    DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() {        DigestAuthenticationEntryPoint entryPoint = new DigestAuthenticationEntryPoint();        entryPoint.setNonceValiditySeconds(3600);        entryPoint.setRealmName("myrealm");        entryPoint.setKey("javaboy");        return entryPoint;    }    DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {        DigestAuthenticationFilter filter = new DigestAuthenticationFilter();        filter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint());        filter.setUserDetailsService(userDetailsServiceBean());        filter.setPasswordAlreadyEncoded(true);        return filter;    }    @Override    @Bean    public UserDetailsService userDetailsServiceBean() throws Exception {        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();        manager.createUser(User.withUsername("javaboy").password("e7ecfd3f08e6960f154e1ff29079fbd3").roles("admin").build());        return manager;    }    @Bean    PasswordEncoder passwordEncoder() {        return NoOpPasswordEncoder.getInstance();    }}
复制代码


  1. 提供 DigestAuthenticationEntryPoint 实例,当用户发起一个没有认证的请求时,需要该实例进行处理。

  2. 创建 DigestAuthenticationFilter 实例,添加到 Spring Security 过滤器链中。

质询

HTTP 摘要认证的质询由 DigestAuthenticationEntryPoint 的 commence 方法完成


public void commence(HttpServletRequest request, HttpServletResponse response,      AuthenticationException authException) throws IOException {   HttpServletResponse httpResponse = response;
// compute a nonce (do not use remote IP address due to proxy farms) // format of nonce is: // base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key)) long expiryTime = System.currentTimeMillis() + (nonceValiditySeconds * 1000); String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + key); String nonceValue = expiryTime + ":" + signatureValue; String nonceValueBase64 = new String(Base64.getEncoder().encode(nonceValue.getBytes()));
// qop is quality of protection, as defined by RFC 2617. // we do not use opaque due to IE violation of RFC 2617 in not // representing opaque on subsequent requests in same session. String authenticateHeader = "Digest realm=\"" + 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); }
httpResponse.addHeader("WWW-Authenticate", authenticateHeader); httpResponse.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());}
复制代码


和 HTTP 基本认证一样,不同的是 WWW-Authenticate 字段值


  • Digest 表示使用 HTTP 摘要认证

  • realm 表示服务端返回的标识访问资源的安全域

  • qop 表示服务端返回的保护级别,auth 表示只进行身份认证;auth-int 表示除了身份认证还要校验内容完整性

  • nonce 为生成的随机数 生成规则:先对过期时间和 key 组成的字符串计算出消息摘要 signatureValue,再对过期时间和 signatureValue 进行 Base64 编码。

  • stale 表示当 nonce 过期了包含该标记,stale=true 表示客户端不必再次弹出输入框,只需要带上已有认证信息,重新发起认证请求即可。

客户端处理

客户端收到请求后,输入用户名密码,然后客户端生成 response,用户密码经过各种 MD5 运算后,包含在 response 中,服务端拿到这些参数后,根据用户名去数据库中查询用户密码,然后进行 MD5 运算,将结果和 response 对比。

请求解析

过滤器 DigestAuthenticationFilter


public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)      throws IOException, ServletException {   HttpServletRequest request = (HttpServletRequest) req;   HttpServletResponse response = (HttpServletResponse) res;
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Digest ")) { chain.doFilter(request, response);
return; }
if (logger.isDebugEnabled()) { logger.debug( "Digest Authorization header received from user agent: " + header); }
DigestData digestAuth = new DigestData(header);
try { digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(), this.authenticationEntryPoint.getRealmName()); } catch (BadCredentialsException e) { fail(request, response, e);
return; }
// Lookup password for presented username // NB: DAO-provided password MUST be clear text - not encoded/salted // (unless this instance's passwordAlreadyEncoded property is 'false') 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); }
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
// If digest is incorrect, try refreshing from backend and recomputing if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) { if (logger.isDebugEnabled()) { 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 notFound) { fail(request, response, new BadCredentialsException(this.messages.getMessage( "DigestAuthenticationFilter.usernameNotFound", new Object[] { digestAuth.getUsername() }, "Username {0} not found")));
return; }
// If digest is still incorrect, definitely reject authentication attempt if (!serverDigestMd5.equals(digestAuth.getResponse())) { if (logger.isDebugEnabled()) { logger.debug("Expected response: '" + serverDigestMd5 + "' but received: '" + digestAuth.getResponse() + "'; is AuthenticationDao returning clear text passwords?"); }
fail(request, response, new BadCredentialsException(this.messages.getMessage( "DigestAuthenticationFilter.incorrectResponse", "Incorrect response"))); return; }
// To get this far, the digest must have been valid // Check the nonce has not expired // We do this last so we can direct the user agent its nonce is stale // but the request was otherwise appearing to be valid if (digestAuth.isNonceExpired()) { fail(request, response, new NonceExpiredException(this.messages.getMessage( "DigestAuthenticationFilter.nonceExpired", "Nonce has expired/timed out")));
return; }
if (logger.isDebugEnabled()) { logger.debug("Authentication success for user: '" + digestAuth.getUsername() + "' with response: '" + digestAuth.getResponse() + "'"); }
Authentication authentication = createSuccessfulAuthentication(request, user); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context);
chain.doFilter(request, response);}
复制代码


太长了 不一一分析了。。


和 HTTP 基本认证相比 最大亮点就是不明文传输用户密码。客户端对密码进行 MD5 运算,并将运算所需参数以及运算结果发送给服务端,服务端再去校验数据是否正确。


HTTP 摘要认证比较复杂 使用并不多。。

发布于: 刚刚阅读数: 4
用户头像

还未添加个人签名 2020.02.29 加入

公众号《盼盼小课堂》,多平台优质博主

评论

发布
暂无评论
HTTP 认证_7月月更_周杰伦本人_InfoQ写作社区