文章目录
前言: 坚持不是件容易的事. 距离上次发言已是半载. 最近更新了个人项目的登录权限模块;增加了手机号认证,到此实现了spring security 双重认证方式.目前可以自行注册体验.
一: Spring Boot 引入Security 的 pom依赖
1.1: 首先引入pom jar 包
spring boot使用security 只需引入以下配置,会自动拉取当前boot版本下的security依赖模块:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当前项目SpringBoot 版本 2.0.4.RELEASE; 引入后可以看到 当前引入了 以下模块版本;spring-security-config;
spring-security-core;spring-security-web;
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
1.2: 配置 WebSecurityConfig
@EnableWebSecurity 作用 :
-
加载了WebSecurityConfiguration配置类, 配置安全认证策略。
在这个配置类中,注入了一个非常重要的bean, bean的name为: springSecurityFilterChain,这是Spring Secuity的核心过滤器, 这是请求的认证入口。 -
加载了AuthenticationConfiguration, 配置了认证信息。
这个类是来配置认证相关的核心类, 这个类的主要作用是,
向spring容器中注入AuthenticationManagerBuilder, AuthenticationManagerBuilder其实是使用了建造者模式,
他能建造AuthenticationManager, 这个类前面提过,是身份认证的入口。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private MyAuthenticationEntryPoint myAuthenticationEntryPoint;
@Autowired
private MyAccessDeniedHandler myAccessDeniedHandler;
@Autowired
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private MyLogoutSuccessHandler myLogoutSuccessHandler;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
// 自定义 账号登录身份认证组件
auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
// 自定义 短信登录身份认证组件
auth.authenticationProvider(new SmsCodeAuthenticationProvider());
}
//各类错误异常处理 以下针对于访问资源路径 认证异常捕获 和 无权限处理
/**
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 项目中用到iframe嵌入网页,然后用到springsecurity就被拦截了 浏览器报错 x-frame-options deny
// 原因是因为springSecurty使用X-Frame-Options防止网页被Frame
http.headers().frameOptions().disable();
// 禁用 csrf(Cross-site request forgery)跨站请求伪造, 由于使用的是JWT,我们这里不需要csrf
//https://blog.csdn.net/yjclsx/article/details/80349906
//处理来自浏览器的请求需要是CSRF保护,如果后台服务是提供API调用那么可能就要禁用CSRF保护
http.cors().and().csrf().disable()
.authorizeRequests()
// 跨域预检请求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
..... 等配置
http.exceptionHandling().authenticationEntryPoint(myAuthenticationEntryPoint).accessDeniedHandler(myAccessDeniedHandler);
// token 校验
http.addFilterBefore(new JwtTokenAuthenticationFilter(),AbstractPreAuthenticatedProcessingFilter.class);
// 开启短信登录认证过滤器
http.addFilterBefore(new SmsCodeLoginFilter(authenticationManager(),myAuthenticationSuccessHandler,myAuthenticationFailureHandler,applicationEventPublisher),UsernamePasswordAuthenticationFilter.class);
// 开启账号登录认证流程过滤器
http.addFilterBefore(new JwtLoginFilter(authenticationManager(),myAuthenticationSuccessHandler,myAuthenticationFailureHandler,applicationEventPublisher), UsernamePasswordAuthenticationFilter.class);
// 退出登录处理器 清除redis 中token GET请求
http.logout().logoutUrl("/logout").logoutSuccessHandler(myLogoutSuccessHandler);
// token 不保存session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
在这个配置类中,我们主要做了以下几个配置:
-
1.访问路径URL的授权策略,如登录、静态资源、回调接口、Swagger访问免登录认证等
-
2.指定了账号登录认证流程过滤器 JwtLoginFilter,由它来触发账号登录认证
- 指定了账号登录自定义身份认证组件 JwtAuthenticationProvider,并注入 UserDetailsService
-
3.指定了短信登录认证流程过滤器 SmsCodeLoginFilter,由它来触发短信登录认证
- 指定了短信登录自定义身份认证组件 SmsCodeAuthenticationProvider
-
4.指定了token访问控制过滤器 JwtTokenAuthenticationFilter,在授权时解析令牌和设置登录状态
-
5.自定义认证成功处理器MyAuthenticationSuccessHandler;
- 认证失败处理器MyAuthenticationFailureHandler;
- 退出成功处理器MyLogoutSuccessHandler
-
6.自定义认证身份验证处理器MyAuthenticationEntryPoint;
- 访问拒绝处理器 MyAccessDeniedHandler
-
7.注入事件监听器 监听登录 退出等事件
二: 账号权限登录流程
账号登录认证流程过滤器 JwtLoginFilter
覆写认证方法,修改用户名、密码的获取方式 覆写认证成功后的操作,移除后台跳转,添加生成令牌并返回给客户端
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
public JwtLoginFilter(AuthenticationManager authManager,
AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler,
ApplicationEventPublisher eventPublisher) {
setAuthenticationManager(authManager);
setAuthenticationSuccessHandler(successHandler);
setAuthenticationFailureHandler(failureHandler);
setApplicationEventPublisher(eventPublisher);
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
// POST 请求 /login 登录时拦截, 由此方法触发执行登录认证流程,可以在此覆写整个登录认证逻辑
super.doFilter(req, res, chain);
}
/**
* 此过滤器的用户名密码默认从request.getParameter()获取,但是这种
* 读取方式不能读取到如 application/json 等 post 请求数据,需要把
* 用户名密码的读取逻辑修改为到流中读取request.getInputStream()
* 在此做验证码的验证
* @param request
* @param response
* @return
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//获取请求实体
String body = HttpRequestUtil.getBody(request);
JSONObject jsonObject = JSON.parseObject(body);
String uuid = jsonObject.getString("uuid");
String code = jsonObject.getString("imgCode");
RedissonObject redissonObject = (RedissonObject)SpringContextUtils.getBeanByClass(RedissonObject.class);
// 查询验证码
String redisCode = redissonObject.getValue(uuid);
// 清除验证码
redissonObject.delete(uuid);
if (StringUtils.isBlank(redisCode)) {
logger.error("验证码不存在或已过期");
throw new MyAuthenticationException("验证码不存在或已过期");
}
if (StringUtils.isBlank(code) || !code.equalsIgnoreCase(redisCode)) {
logger.error("验证码错误");
throw new MyAuthenticationException("验证码错误");
}
//账户和密码
String username = jsonObject.getString("username");
String password = jsonObject.getString("password");
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
JwtAuthenticatioToken authRequest = new JwtAuthenticatioToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
-
1: attemptAuthentication方法 把用户名密码的读取逻辑修改为到流中读取request.getInputStream() 校验验证码;
-
2:this.getAuthenticationManager().authenticate(authRequest) 账号和密码 传递到认证管理ProviderManager
-
3:ProviderManager 下循环遍历多个认证器provider;if (!provider.supports(toTest)) 判断当前 Authentication 是否支持
当前是账号登录;此处 JwtAuthenticatioToken.class.isAssignableFrom(aClass) 为true 进行用户名登录认证; -
4: 找到自定义的 JwtAuthenticationProvider 进行认证;此处可以复写部分功能扩展认证;再调用 父类AbstractUserDetailsAuthenticationProvider的super.authenticate(authentication)方法;
-
5:AbstractUserDetailsAuthenticationProvider 调用子类DaoAuthenticationProvider的retrieveUser()方法;
-
6:DaoAuthenticationProvider执行实际获取用户 UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username)获取用户;
-
7:找到我们自定义实现 UserDetailsService的类 UserDetailsServiceImpl;通过用户名查询用户判断是否存在;查询权限封装;
-
8:AbstractUserDetailsAuthenticationProvider 再调用DefaultPreAuthenticationChecks检查当前用户preAuthenticationChecks.check(user)判断当前用户isAccountNonLocked()、isEnabled()、isAccountNonExpired()、isCredentialsNonExpired();
-
9:AbstractUserDetailsAuthenticationProvider再调用 DaoAuthenticationProvider的additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication)验证密码; -
10: 如果登录过程出现失败:MyAuthenticationFailureHandler将处理失败信息json回写;
- 登录成功触发:MyAuthenticationSuccessHandler onAuthenticationSuccess() 将登录信息记录到redis
-
11: 交互认证成功 监听器AuthenticationEventLogger 监听到事件 InteractiveAuthenticationSuccessEvent 记录登录日志;
三: 手机号权限登录流程
手机号登录认证流程过滤器 SmsCodeLoginFilter
手机号登录采用自己的认证器,对于认证结果成功和失败处理采用公共的处理器;
public class SmsCodeLoginFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_MOBILE_KEY = "mobile";
public static final String SPRING_SECURITY_CODE_KEY = "code";
private String mobileParameter = SPRING_SECURITY_MOBILE_KEY;
private String codeParameter = SPRING_SECURITY_CODE_KEY;
private boolean postOnly = true;
public SmsCodeLoginFilter(AuthenticationManager authManager,
AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler,
ApplicationEventPublisher eventPublisher) {
super(new AntPathRequestMatcher("/mobile/login", "POST"));
setAuthenticationManager(authManager);
setAuthenticationSuccessHandler(successHandler);
setAuthenticationFailureHandler(failureHandler);
setApplicationEventPublisher(eventPublisher);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String body = HttpRequestUtil.getBody(request);
JSONObject jsonObject = JSON.parseObject(body);
String mobile = jsonObject.getString(mobileParameter);
String code = jsonObject.getString(codeParameter);
//校验code
RedissonObject redissonObject = (RedissonObject)SpringContextUtils.getBeanByClass(RedissonObject.class);
String msgCode = redissonObject.getValue(mobile);
// 清除短信验证码
redissonObject.delete(mobile);
if (StringUtils.isBlank(msgCode)) {
logger.error("验证码不存在或已过期");
throw new MyAuthenticationException("验证码不存在或已过期");
}
if (StringUtils.isBlank(code) || !code.equalsIgnoreCase(msgCode)) {
logger.error("验证码错误");
throw new MyAuthenticationException("验证码错误");
}
if (mobile == null) {
mobile = "";
}
if (code == null) {
code = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile,code);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
-
1: attemptAuthentication方法,同样把手机号和验证码获取到,并此处校验验证码;
-
2: this.getAuthenticationManager().authenticate(authRequest)手机号和验证码 传递到认证管理ProviderManager
-
3 :ProviderManager 下循环遍历多个认证器provider;if (!provider.supports(toTest)) 判断当前 Authentication 是否支持
当前是账号登录;此处 SmsCodeAuthenticationToken.class.isAssignableFrom(aClass); 为true 进行短信登录认证; -
4: 找到自定义的 SmsCodeAuthenticationProvider 进行认证;此处相比用户名登录简化了没有用户密码判断;用户状态判断;直接查询用户权限
-
5: 如果登录过程出现失败:MyAuthenticationFailureHandler将处理失败信息json回写;
- 登录成功触发:MyAuthenticationSuccessHandler onAuthenticationSuccess() 将登录信息记录到redis
-
6: 交互认证成功 监听器AuthenticationEventLogger 监听到事件 InteractiveAuthenticationSuccessEvent 记录登录日志;
四: token 过滤认证校验
token 认证过滤器 JwtTokenAuthenticationFilter
由于前后分离的权限 无状态化 无session 我们将登录用户信息放入缓存 监控登录用户时效管理
public class JwtTokenAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//获取登录接口
String requestUrl = request.getRequestURI();
// 如果不为登录接口 则处理token
if (!requestUrl.endsWith("/login")) {
// 获取token, 并检查登录状态
String token = request.getHeader("token");
// token未过期
if (token != null && !JwtTokenUtils.isTokenExpired(token)) {
RedissonObject redissonObject = (RedissonObject) SpringContextUtils.getBeanByClass(RedissonObject.class);
//缓存中拿取数据
JwtUserRedis jwtUserRedis = redissonObject.getValue("user:"+token);
//没有获取到值
if (jwtUserRedis == null) {
chain.doFilter(request,response);
return;
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(jwtUserRedis.getUsername(),null,jwtUserRedis.getAuthorities());
// 认证放入线程
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request,response);
}else{
chain.doFilter(request,response);
}
}
}
-
对于非登录请求判断 拿取请求头中的 token判断是否过期;过期直接跳过后续会进行权限返回无法访问;
-
未过期则从redis 获取信息;如果为空直接跳过去执行后面过滤器;不为空则封装用户权限到当前线程认证;
-
当前线程存在登录用户信息后,后续可以进行校验通过操作;
完整项目代码以及体验地址:
项目代码: https://gitee.com/sinaC/youliao
体验地址: http://132.232.43.102