Spring Security HTTP认证
Tony-devj 人气:0Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC(控制反转),DI(依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
前言
除系统内维护的用户名和密码认证技术外,Spring Security还支持HTTP层面的认证,包括HTTP基本认证和HTTP摘要认证
一、HTTP基本认证是什么?
HTTP基本认证是在RFC2616中定义的一种认证模式。
二、HTTP基本认证流程
- 客户端发起一条没有携带认证信息的请求。
- 服务器返回一条401 Unauthorized响应, 并在WWW-Authentication首部说明认证形式, 当进行HTTP基本认证时, WWW-Authentication会被设置为Basic realm=“被保护页面”。
- 客户端收到401 Unauthorized 响应后, 弹出对话框, 询问用户名和密码。 当用户完成后, 客户端将用户名和密码使用冒号拼接并编码为Base64形式, 然后放入请求的Authorization首部发送给服务器。
- 服务器解码得到客户端发来的用户名和密码,并在验证它们是正确的之后,返回客户端请求的报文
有上面可以看出只需要验证Authentication即可,因此如果不使用浏览器访问HTTP基本认证保护的页面,则自行在请求头中设置Authorization
也是可以.
HTTP基本认证是一种无状态的认证方式,与表单认证相比,HTTP基本认证是一种基于HTTP层面的认证方式,无法携带session,即无法实现Remember-ME功能。另外,用户名和密码在传递时仅做一次简单的Base64
编码,几乎等同于明文传输,极易出现密码被窃听和重放攻击等安全性问题,在实际系统开发中很少使用这种方式来进行安全验证。 如果有必要,也应使用加密的传输层HTTPS
来保障安全.
一.Spring Security使用HTTP基本认证
1.创建项目spring-security-http-auth
pom.xml:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
2.创建配置文件WebSecurityConfig
@EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated().and().httpBasic(); } }
上面的配置最后添加了httpBasic()
,使用http基本认证
3.运行项目
访问本地项目,http://localhost:8080
会弹出登陆框,我们看到调试工具中返回了401无权限。
我们使用Spring Security提供的默认的用户名和密码登陆。
登陆成功后,header中就会有Authorization: Basic dXNlcjo0NWU2NzViOC1hZGYwLTQzNzMtYjA2MS02MGE0YzkzZjA2ZGU=
二.Spring Security HTTP基本认证原理
上面我们实现了HTTP基本认证,我们看看其中Spring Security中是如何做到的?
我们使用HTTP基本认证的时候,在配置类中使用httpBasic()
进行处理。
httpBasic方法:
public HttpBasicConfigurer<HttpSecurity> httpBasic() throws Exception { return (HttpBasicConfigurer)this.getOrApply(new HttpBasicConfigurer()); }
上面可以看出,Spring Security
进行HTTP基本认证是使用HttpBasicConfigurer
配置类进行的。HttpBasicConfigurer.class
:
//构建HttpBasicConfigurer public HttpBasicConfigurer() { this.realmName("Realm"); LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap(); entryPoints.put(X_REQUESTED_WITH, new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); DelegatingAuthenticationEntryPoint defaultEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints); defaultEntryPoint.setDefaultEntryPoint(this.basicAuthEntryPoint); this.authenticationEntryPoint = defaultEntryPoint; } //进行配置 public void configure(B http) { //进行认证管理 AuthenticationManager authenticationManager = (AuthenticationManager)http.getSharedObject(AuthenticationManager.class); //声明basic认证拦截器 BasicAuthenticationFilter basicAuthenticationFilter = new BasicAuthenticationFilter(authenticationManager, this.authenticationEntryPoint); if (this.authenticationDetailsSource != null) { basicAuthenticationFilter.setAuthenticationDetailsSource(this.authenticationDetailsSource); } //注册一个RememberMeServices RememberMeServices rememberMeServices = (RememberMeServices)http.getSharedObject(RememberMeServices.class); if (rememberMeServices != null) { //设置rememberMeServices basicAuthenticationFilter.setRememberMeServices(rememberMeServices); } //申明basicAuthenticationFilter过滤器 basicAuthenticationFilter = (BasicAuthenticationFilter)this.postProcess(basicAuthenticationFilter); http.addFilter(basicAuthenticationFilter); }
上面声明BasicAuthenticationFilter
并添加到拦截器链中BasicAuthenticationFilter.class
:
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { try { //获取token UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request); //authRequest为空直接放行 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 (this.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.onSuccessfulAuthentication(request, response, authResult); } } catch (AuthenticationException var8) { SecurityContextHolder.clearContext(); this.logger.debug("Failed to process authentication request", var8); this.rememberMeServices.loginFail(request, response); this.onUnsuccessfulAuthentication(request, response, var8); if (this.ignoreFailure) { chain.doFilter(request, response); } else { this.authenticationEntryPoint.commence(request, response, var8); } return; } chain.doFilter(request, response); }
BasicAuthenticationEntryPoint
返回进行响应的处理
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { //添加响应响应头 response.addHeader("WWW-Authenticate", "Basic realm=\"" + this.realmName + "\""); response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); }
三.HTTP摘要认证是什么?
HTTP摘要认证和HTTP基本认证一样,也是在RFC2616中定义的认证模式,RFC2617专门对这两种认证模式做了规定。与 HTTP 基本认证相比,HTTP 摘要认证使用对通信双方都可知的口令进行校验,且最终的传输数据并非明文形式。
摘要认证是一种协议规定的Web服务器用来同网页浏览器进行认证信息协商的方法。它在密码发出前,先对其应用哈希函数,这相对于HTTP基本认证发送明文而言,更安全。
从技术上讲,摘要认证是使用随机数来阻止进行密码分析的MD5加密哈希函数应用。
HTTP摘要认证流程:
HTTP摘要认证中的相关参数:
- username: 用户名。
- password: 用户密码。
- realm: 认证域, 由服务器返回。
- opaque: 透传字符串, 客户端应原样返回。
- method: 请求的方法。
- nonce: 由服务器生成的随机字符串。
- nc: 即nonce-count, 指请求的次数, 用于计数, 防止重放攻击。 qop被指定时, nc也必须被指定。
- cnonce: 客户端发给服务器的随机字符串, qop被指定时, cnonce也必须被指定。
- qop: 保护级别, 客户端根据此参数指定摘要算法。 若取值为auth, 则只进行身份验证; 若取
- 值为auth-int, 则还需要校验内容完整性。
- uri: 请求的uri。
- response:客户端根据算法算出的摘要值。
- algorithm:摘要算法, 目前仅支持MD5。
- entity-body:页面实体,非消息实体,仅在auth-int中支持。
- 通常服务器携带的数据包括realm、 opaque、 nonce、 qop等字段, 如果客户端需要做出验证回应,就必须按照一定的算法计算得到一些新的数据并一起返回。
四.Spring Security使用HTTP摘要认证流程?
在Spring Security中没有像HTTP基础认证那样,通过httpBasic()方法进行集成HTTP摘要认证,但是Spring Security提供了像BasicAuthenticationEntryPoint
一样的DigestAuthenticationEntryPoint
.就是我们需要将DigestAuthenticationEntryPoint
添加到filter过滤器中去处理。
代码如下:WebSecurityConfig
类:
@EnableWebSecurity public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private DigestAuthenticationEntryPoint digestAuthenticationEntryPoint; @Autowired private UserDetailsService userDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated().and() .exceptionHandling() .authenticationEntryPoint(digestAuthenticationEntryPoint) .and().addFilter(digestAuthenticationFilter()); } public DigestAuthenticationFilter digestAuthenticationFilter(){ DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter(); digestAuthenticationFilter.setUserDetailsService(userDetailsService); digestAuthenticationFilter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint); return digestAuthenticationFilter; } }
申明DigestAuthenticationEntryPoint
Bean:
@Bean public DigestAuthenticationEntryPoint digestAuthenticationEntryPoint(){ DigestAuthenticationEntryPoint digestAuthenticationEntryPoint = new DigestAuthenticationEntryPoint(); digestAuthenticationEntryPoint.setRealmName("realName"); digestAuthenticationEntryPoint.setKey("tony"); return digestAuthenticationEntryPoint; } @Bean public DigestAuthenticationEntryPoint digestAuthenticationEntryPoint(){ DigestAuthenticationEntryPoint digestAuthenticationEntryPoint = new DigestAuthenticationEntryPoint(); digestAuthenticationEntryPoint.setRealmName("realm"); digestAuthenticationEntryPoint.setKey("tony"); return digestAuthenticationEntryPoint; } @Bean public UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("tony").password("123456").roles("admin").build()); return manager; } @Bean public PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); }
运行项目
访问主页,http://localhost:8080,返回如下页面:
我们输入用户名和密码登陆。
当长时间未登录,随机字符串到期了也登陆不上。
默认的过期时间为300s,我们可以通过设置时间。DigestAuthenticationEntryPoint
中realmName和key是必须要设置的。
相关源码:
public void afterPropertiesSet() { Assert.hasLength(this.realmName, "realmName must be specified"); Assert.hasLength(this.key, "key must be specified"); } public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { //计算过期时间 long expiryTime = System.currentTimeMillis() + (long)(this.nonceValiditySeconds * 1000); //计算签名值 String signatureValue = DigestAuthUtils.md5Hex(expiryTime + ":" + this.key); //随机字符串 String nonceValue = expiryTime + ":" + signatureValue; //随机字符串base64 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)); response.addHeader("WWW-Authenticate", authenticateHeader); response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); }
进行处理的时候使用DigestAuthenticationFilter
进行处理
public void afterPropertiesSet() { //必须设置userDetailsService Assert.notNull(this.userDetailsService, "A UserDetailsService is required"); //必须设置authenticationEntryPoint Assert.notNull(this.authenticationEntryPoint, "A DigestAuthenticationEntryPoint is required"); } private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Digest ")) { logger.debug(LogMessage.format("Digest Authorization header received from user agent: %s", header)); DigestAuthenticationFilter.DigestData digestAuth = new DigestAuthenticationFilter.DigestData(header); try { //验证并且解密 digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(), this.authenticationEntryPoint.getRealmName()); } catch (BadCredentialsException var11) { this.fail(request, response, var11); 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 (!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 var12) { String message = this.messages.getMessage("DigestAuthenticationFilter.usernameNotFound", new Object[]{digestAuth.getUsername()}, "Username {0} not found"); this.fail(request, response, new BadCredentialsException(message)); return; } String message; if (!serverDigestMd5.equals(digestAuth.getResponse())) { logger.debug(LogMessage.format("Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?", serverDigestMd5, digestAuth.getResponse())); message = this.messages.getMessage("DigestAuthenticationFilter.incorrectResponse", "Incorrect response"); this.fail(request, response, new BadCredentialsException(message)); } else if (digestAuth.isNonceExpired()) { message = this.messages.getMessage("DigestAuthenticationFilter.nonceExpired", "Nonce has expired/timed out"); this.fail(request, response, new NonceExpiredException(message)); } else { logger.debug(LogMessage.format("Authentication success for user: '%s' with response: '%s'", digestAuth.getUsername(), digestAuth.getResponse())); Authentication authentication = this.createSuccessfulAuthentication(request, user); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); chain.doFilter(request, response); } } else { chain.doFilter(request, response); } }
DigestData
为摘要数据:
private class DigestData { //用户名 private final String username; //认证域 private final String realm; //随机字符串 private final String nonce; private final String uri; private final String response; //保护级别 private final String qop; //即nonce-count, 指请求的次数, 用于计数, 防止重放攻击 private final String nc; private final String cnonce; private final String section212response; private long nonceExpiryTime; DigestData(String header) { this.section212response = header.substring(7); String[] headerEntries = DigestAuthUtils.splitIgnoringQuotes(this.section212response, ','); Map<String, String> headerMap = DigestAuthUtils.splitEachArrayElementAndCreateMap(headerEntries, "=", "\""); this.username = (String)headerMap.get("username"); this.realm = (String)headerMap.get("realm"); this.nonce = (String)headerMap.get("nonce"); this.uri = (String)headerMap.get("uri"); this.response = (String)headerMap.get("response"); this.qop = (String)headerMap.get("qop"); this.nc = (String)headerMap.get("nc"); this.cnonce = (String)headerMap.get("cnonce"); DigestAuthenticationFilter.logger.debug(LogMessage.format("Extracted username: '%s'; realm: '%s'; nonce: '%s'; uri: '%s'; response: '%s'", new Object[]{this.username, this.realm, this.nonce, this.uri, this.response})); } //验证和解密 void validateAndDecode(String entryPointKey, String expectedRealm) throws BadCredentialsException { if (this.username != null && this.realm != null && this.nonce != null && this.uri != null && this.response != null) { if ("auth".equals(this.qop) && (this.nc == null || this.cnonce == null)) { DigestAuthenticationFilter.logger.debug(LogMessage.format("extracted nc: '%s'; cnonce: '%s'", this.nc, this.cnonce)); throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.missingAuth", new Object[]{this.section212response}, "Missing mandatory digest value; received header {0}")); } else if (!expectedRealm.equals(this.realm)) { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.incorrectRealm", new Object[]{this.realm, expectedRealm}, "Response realm name '{0}' does not match system realm name of '{1}'")); } else { byte[] nonceBytes; try { nonceBytes = Base64.getDecoder().decode(this.nonce.getBytes()); } catch (IllegalArgumentException var8) { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceEncoding", new Object[]{this.nonce}, "Nonce is not encoded in Base64; received nonce {0}")); } String nonceAsPlainText = new String(nonceBytes); String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":"); if (nonceTokens.length != 2) { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceNotTwoTokens", new Object[]{nonceAsPlainText}, "Nonce should have yielded two tokens but was {0}")); } else { try { this.nonceExpiryTime = new Long(nonceTokens[0]); } catch (NumberFormatException var7) { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceNotNumeric", new Object[]{nonceAsPlainText}, "Nonce token should have yielded a numeric first token, but was {0}")); } String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey); if (!expectedNonceSignature.equals(nonceTokens[1])) { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceCompromised", new Object[]{nonceAsPlainText}, "Nonce token compromised {0}")); } } } } else { throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.missingMandatory", new Object[]{this.section212response}, "Missing mandatory digest value; received header {0}")); } } //计算服务摘要 String calculateServerDigest(String password, String httpMethod) { //生产摘要 return DigestAuthUtils.generateDigest(DigestAuthenticationFilter.this.passwordAlreadyEncoded, this.username, this.realm, password, httpMethod, this.uri, this.qop, this.nonce, this.nc, this.cnonce); } //判断随机数是否到期 boolean isNonceExpired() { long now = System.currentTimeMillis(); return this.nonceExpiryTime < now; } String getUsername() { return this.username; } String getResponse() { return this.response; } }
加载全部内容