HTTP 认证
通过 HTTP 请求头来提供认证信息,而不是通过表单登录。有 HTTP Basic authentication HTTP Digest authentication
HTTP Basic authentication
HTTP Basic authentication 将用户的登录用户名 密码经过 Base64 编码之后,放在请求头的 Authorization 字段中,从而完成用户身份的认证。
客户端发送请求
服务端收到请求后,发现用户还没有认证,返回状态码 401 WWW-Authenticate 响应头则定义了使用何种验证方式去完成身份认证,最简单最常见的是 HTTP 基本认证(Basic),还有 Bearer(Oauth2.0 认证),Digest(HTTP 摘要认证)
客户端收到服务端响应后,将用户名密码使用 Base64 编码后,放在请求头中,再次发送请求
服务端解析 Authorization 字段,完成用户身份的校验,最后将资源返回给客户端。
这种认证方式很少使用,因为有安全问题,HTTP 基本认证没有对传输的凭证信息进行加密,仅仅只是进行了 Base64 编码。
通过 httpBasic()方法即可开启 HTTP 基本认证。
Security 实现 HTTP 基本认证分为两部分:
对未认证的请求发出质询
解析携带认证信息的请求。
对未认证的请求发出质询
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);}
复制代码
调用 authenticationConverter.convert 方法,对请求头中的 Authorization 字段进行解析,经过 Base64 解码后的用户名密码用冒号隔开,然后构造出 UsernamePasswordAuthenticationToken 的实例 authRequest
如果 authRequest 为 null,说明请求头中没有包含认证信息,直接指向接下来的过滤器,最终通过 ExceptionTranslationFilter 过滤器链中调用 BasicAuthenticationEntryPoint 的 commence 方法进行处理。如果不为 null,说明请求携带了认证信息,那么对认证信息进行校验。
具体校验为从 authRequest 中提取出用户名,然后调用 authenticationIsRequired 方法判断是否需要认证,不需要认证执行下一个过滤器,需要认证则进行认证。authenticationIsRequired 是从 SecurityContextHolder 中渠道当前登录对象,判断是否已经登录过。
调用 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(); }}
复制代码
提供 DigestAuthenticationEntryPoint 实例,当用户发起一个没有认证的请求时,需要该实例进行处理。
创建 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 摘要认证比较复杂 使用并不多。。
评论