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
需要自定义配置
@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());
}
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 摘要认证比较复杂 使用并不多。。
评论