在开发实际应用项目当中,肯定存在用户登录和授权的过程,之前我们使用自己开发的权限框架或者 Shiro 来做这块内容的扩展和延伸,今天使用 Spring 框架自身的权限框架来集成下,也就是 Spring Security。
Spring Security 核心功能包括以下三个部分:
1)认证,解决你是谁的问题,也即用户登录;
2)授权,解决你可以干什么的问题,并不是你登录就可以为所欲为;
3)攻击防护,解决防止别人伪造身份问题,
1、基于表单的认证
1.1第一印象
导入 springsecurity 的依赖包之后,我们的项目启动会自动开启基本安全认证,认证的用户名是 user,密码可以在控制台找到,形如
image
打开一个 URL,就可以看到需要登录验证
image
输入账号密码就可以正常使用。
当然这种情况是无法满足实际应用的,我们需要自己的用户名和密码来进行登录认证。
记下来首先配置一些 开启使用Springsecurity 的基本配置,在配置包里面新建一个类WebSecurityConfig
,继承WebSecurityConfigurerAdapter
,然后复写configure(HttpSecurity http)
方法,具体代码如下
@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter{ @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin()//表单登录,身份认证 .and() .authorizeRequests()//对请求授权 .anyRequest()//任何请求 .authenticated();//需要身份认证 } }
然后重启,进行访问,此时将会出现一个默认登录框,如下
image
账号密码和之前相同,可以测试,注意 URL 的跳转。
其实这个和之前没什么区别,也的确没什么区别,主要是原来默认的是http.httpBasic()
改为了http.formLogin()
.
1.2基本原理
其实 Springsecurity 就是一组过滤器,形成过滤器练,所有的访问请求都会经过这些过滤器,这些过滤器在系统启动的时候自行配置到链中,无需开发者关心。
过滤器链上有很多过滤器,其中比较主要的几个
1、UserPasswordAuthenticationFilter、BaseicAuthenticationFilter 是认证用户身份的,每个过滤器就是一种验证方式。和上面对应的就是
http.httpBasic()---->BaseicAuthenticationFilter
http.formLogin()---->UserPasswordAuthenticationFilter
这两个过滤器都是检查请求里面是否包含过滤器需要的信息。比如先经过UserPasswordAuthenticationFilter这个过滤器,那么就需要先查看是否是一个登陆请求,是否包含用户名和密码,如果没有这些信息,那么就到BaseicAuthenticationFilter过滤器,会检查请求头里面是否包含需要的信息。
2、一旦通过认证,会有相应的标记进行记录,然后继续想后面的过滤器传递。最后到达链的终端是FilterSecurityInterceptor,他是整个过滤器链最终守门人,他决定该请求能否顺利的访问 Controller 里面的服务。也就是说前面的链不管结论如何都会走到最后整个过滤器,有他来决定是往后继续执行业务,还是抛出某个异常。
3、一旦有异常出现,会在倒数第二层的过滤器来处理这些异常,这个过滤器是ExceptionTranslationFilter。他会根据具体的异常会导向不同的页面。
image
上图中,除了第一类(绿色)的我们可以控制,第二第三(橙色和蓝色)是不可控制的,他们一定在过滤器链的末端。
深入源码
分别在绿色的 UserPasswordAuthenticationFilter,蓝色的异常处理过滤器,橙色的过滤器,以及自己的 Rest 服务 上打上断点。
首先在 Controller 的方法上打个断点
image
其次在FilterSecurityInterceptor的124行地方打断点
[图片上传失败...(image-c0c504-1527080640040)]
再次在ExceptionTranslationFilter 的123行的地方打上断点
image
最后在UsernamePasswordAuthenticationFilter的获取用户名和面膜的地方打上断点
image
然后我们开始运行一个请求,比如http://localhost:8888/users?username=123
。
首先断点停在FilterSecurityInterceptor
这个类,运维前面的过滤器对这个 URL 都不care,由于我们配置了所有请求都需要身份验证,那么这关肯定过不去的。在执行beforeInvocation
的时候跑出一个异常,这个异常跑出来之后,被ExceptionTranslationFilter过滤器捕获到了。然后对异常的处理,处理结果是一个重定向到一个登陆页面。
接下来进行登录,这次停在UsernamePasswordAuthenticationFilter
这个过滤器上,说明登录请求被诸葛过滤器抓住了,并且开始进行验证登录结果。
再继续就又到橙色的FilterSecurityInterceptor
类上,其实这之间由个跳转的,登录 URL 处理完毕,正常登录后又回到http://localhost:8888/users?username=123
这个请求,其实是这个请求走到了橙色过滤器上。一旦InterceptorStatusToken token = super.beforeInvocation(fi);
执行完毕,就到实际业务代码里面了。
1.2自定义用户认证逻辑
1.2.1用户信息获取
这部分功能被封装在UserDetailsService
这个接口里面。这个接口只有一个方法UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
接受用户名,返回UserDetails对象。创建一个类,实现UserDetailsService,并且实现方法,代码实现如下,为了简便处理,忽略了数据层
@Componentpublic class CustomerUserDetailService implements UserDetailsService{ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //根据用户名去数据库查询用户信息 //可以注入 jdbc,mybatis 等 DAO //这里方便演示,直接在代码里面做了 //User 对象已经实现了UserDetails //AuthorityUtils.commaSeparatedStringToAuthorityList 方法是以逗号分割产生一个授权集合 User user=new User(username, "123456", AuthorityUtils.commaSeparatedStringToAuthorityList("admin")); return user; } }
这下可以使用自己的用户登录逻辑了。
1.2.2校验逻辑
主要是密码是否匹配,比如取出123456密码之后交给 Springsecurity 就可以了。
其次其他的一些校验,密码过期,用户冻结等。
主要看 UserDetails
这个接口,里面包含了所有信息
image
后面4个布尔返回方法可以执行自己的校验逻辑
isAccountNonExpired----账号是否过期
isAccountNonLocked----账号是否冻结,可恢复
isCredentialsNonExpired----密码是否过期
isEnabled----账号是否删除,不可恢复
我们在构造 User 的时候使用有7参数的构造,改写
User user=new User(username, "123456", true,//账号可用 true,//账号不过期 true,//密码不过期 true,//账号没有锁定 AuthorityUtils.commaSeparatedStringToAuthorityList("admin")); return user;
1.2.3加密解密
实际应用密码取出应该是一个加密的密码,而不是明文。Springsecurity 的密码加密在接口PasswordEncoder
中处理。该接口有2个方法,分别是String encode(CharSequence rawPassword);
负责加密,用户注册的时候对明文加密,存到 DB。boolean matches(CharSequence rawPassword, String encodedPassword);
负责匹配
为了使用加密功能,这里使用一个美人的实现BCryptPasswordEncoder
,把这个 bean 添加到配置中。
image
为了演示,这里直接使用
image
1.3个性化登录
1、自定义用户登录页
之前 SpringSecurity 自带的登录页当然不能正常使用,我们需要自行定制一个,首先在配置里面增加一行登录页的名称
image
然后开始构建这个页面
image
需要注意的是:这个 login.html 需要排除请求认证之外。
image
这样我们就能使用自己的登录页面了。
image
登录的请求地址是/userlogin
,form是<form name="f" action="/userlogin" method="POST">
。默认的 UsernamePasswordAuthenticationFilter
过滤器验证的是
image
这样的请求,而我们现在改了,也需要修改配置
image
进行访问出现跨站防护的问题,如下图
image
这里暂时关闭这个功能
@Override protected void configure(HttpSecurity http) throws Exception { http.formLogin()//表单登录,身份认证 .loginPage("/login.html")//设置登录页 .loginProcessingUrl("/userlogin")//设置表单提交请求的 URL .and() .authorizeRequests()//对请求授权 .antMatchers("/login.html").permitAll()//表示对这个 url 永远的通过 .anyRequest()//任何请求 .authenticated()//需要身份认证 .and() .csrf().disable();//把 csdf 跨站防护关闭 }
接下来针对不同类型的请求,应该返回不同的内容,如下图所示,一个请求是否需要认证(由 Springsecurity 决定),一旦需要认证,那么久转入一个 Controller 里面,进行判断,如果来源是 HTML,那么久跳转到登录页面,如果是 ajax 请求什么的,那么返回 JSON 内容什么的。
image
接下来实现他,首先编写 Controller
@RestControllerpublic class WebSecurityController { //需要把当前的请求缓存到 session 里面 private RequestCache requestCache=new HttpSessionRequestCache(); private RedirectStrategy redirectStrategy=new DefaultRedirectStrategy(); @RequestMapping("/authlogin") @ResponseStatus(code=HttpStatus.UNAUTHORIZED)//401 未授权 public SimpleResponse requestAuthentication(HttpServletRequest request,HttpServletResponse response) throws IOException{ //拿到引发跳转的请求 SavedRequest savedRequest = requestCache.getRequest(request, response); if(savedRequest!=null){ String redirectUrl = savedRequest.getRedirectUrl();//引发跳转的请求 URL if(redirectUrl.endsWith(".html")){//是否是以 HTML 结尾 //跳转登录页 这个登录页可以配置到外面,使得程序更加灵活 redirectStrategy.sendRedirect(request, response, "login.html"); } } return new SimpleResponse("访问服务需要认证,引导用户到登录页"); } }
代码重构
由于上面的登录页是写死在代码里面,需要移植到可配置层面,现在为了全局考虑,进行总体进行一个设计
image
1、构建 类 SecurityProperties
@ConfigurationProperties(prefix="cn.ts")public class SecurityProperties { //以 cn.ts.web配置的读到这个对象里面 private WebProperties web=new WebProperties(); public WebProperties getWeb() { return web; } public void setWeb(WebProperties webProperties) { this.web = webProperties; } }
2、构建 WebProperties
public class WebProperties { //登录页 cn.ts.web.loginPage //如果用户配置这个值,就使用配置了的,否则使用默认的 private String loginPage="/login.html"; public String getLoginPage() { return loginPage; } public void setLoginPage(String loginPage) { this.loginPage = loginPage; } }
3、构建配置类,装载SecurityProperties
@Configuration@EnableConfigurationProperties(SecurityProperties.class)public class CoreConfiguration { }
4、修改 Controller 代码
image
5、修改SS 配置代码
@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter{ @Autowired private SecurityProperties securityProperties; @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin()//表单登录,身份认证 .loginPage("/authlogin")//设置登录页 .loginProcessingUrl("/userlogin")//设置表单提交请求的 URL .and() .authorizeRequests()//对请求授权 .antMatchers("/authlogin", "/login.html",//默认的登录页 securityProperties.getWeb().getLoginPage()) .permitAll()//表示对这个 url 永远的通过 .anyRequest()//任何请求 .authenticated()//需要身份认证 .and() .csrf().disable();//把 csdf 跨站防护关闭 } }
1.4登录成功和失败页面处理
在 SS 中处理登录成功之后的处理比较简单,只需要实现AuthenticationSuccessHandler
即可。
我们来实现这个接口
@Componentpublic class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler{ @Autowired private ObjectMapper objectMapper; /** * * Authentication封装了我们的登录信息,包括登录之前信息和 UserDetail * @see org.springframework.security.web.authentication.AuthenticationSuccessHandler#onAuthenticationSuccess(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, org.springframework.security.core.Authentication) */ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("登录成功"); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(authentication)); } }
这里就是把authentication包装成 JSON 格式返回到前端。
接下来配置这个成功处理器,注入这个处理器到 SS 配置类,设置如下图
image
有了成功处理,现在开始定制失败处理的类,与上面类似,不过实现的是AuthenticationFailureHandler
,该接口里面需要实现的方法是onAuthenticationFailure
,其里面参数AuthenticationException
可以看下图,是他的子类实现。
image
而我们自定义的失败处理类和上面成功类相似,唯一不同的是返回状态需要修改为500
@Componentpublic class MyAuthenticationFailureHandler implements AuthenticationFailureHandler{ @Autowired private ObjectMapper objectMapper; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { System.out.println("登录失败"); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());//500错误 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(exception)); } }
同时也需要在 SS 配置类上配置,如下图所示
image
然后启动服务器,故意输入错误密码,可以看到打出一大片错误信息,同时返回状态码是500,控制台也打出登录错误。
成功登录一次,可以看到控制台打出成功登录信息。
image
为了让成功和失败能够同时处理页面跳转和返回 JSON两种方案,需要重构下上面两个实现。
于是我们先构建一个枚举
public enum LoginType { REDIRECT, JSON }
在 WebProperties 里面引入这个属性
public class WebProperties { //登录页 cn.ts.web.loginPage //如果用户配置这个值,就使用配置了的,否则使用默认的 private String loginPage="/login.html"; private LoginType loginType=LoginType.JSON; public String getLoginPage() { return loginPage; } public void setLoginPage(String loginPage) { this.loginPage = loginPage; } public LoginType getLoginType() { return loginType; } public void setLoginType(LoginType loginType) { this.loginType = loginType; } }
接下来,需要变动成功处理器,改成继承一个默认的处理器SavedRequestAwareAuthenticationSuccessHandler
具体代码如下
@Componentpublic class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler{ @Autowired private ObjectMapper objectMapper; @Autowired private SecurityProperties securityProperties; /** * * Authentication封装了我们的登录信息,包括登录之前信息和 UserDetail */ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("登录成功"); if(LoginType.JSON.equals(securityProperties.getWeb().getLoginType())){ response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(authentication)); }else{ //父类的方法就是跳转 super.onAuthenticationSuccess(request, response, authentication); } } }
同样的做法,修改失败处理
@Componentpublic class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler{ @Autowired private ObjectMapper objectMapper; @Autowired private SecurityProperties securityProperties; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { System.out.println("登录失败"); if(LoginType.JSON.equals(securityProperties.getWeb().getLoginType())){ response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());//500错误 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(exception)); }else{ //跳到父类的错误页面上 super.onAuthenticationFailure(request, response, exception); } } }
配置文件
image
上面分析了若干接口,以及如何使用,接下来仔细分析下源码是怎么把这些东西串起来的。
源码分析
1、认证处理流程说明
image
首先来看看这个流程涉及哪些类,是如何进行的。
image
这张图涉及到了核心一些类,接下来我们就就着这样的流程看源码。
我们开始登录,首先进入的是绿色的UsernamePasswordAuthenticationFilter
,是 SS 过滤器链上的一个过滤器,处理用户表单登录,他根据用户名和密码构造了一个UsernamePasswordAuthenticationToken
类。
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password);
这个对象向上寻找,可以追溯到他是Authentication
的一个实现。再看他的构造函数
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super(null);//调用父类的空构造,权限设置为空 this.principal = principal; this.credentials = credentials; setAuthenticated(false);//还没有进行验证 }
然后来到this.getAuthenticationManager().authenticate(authRequest)
最后一行,也就来到上图的第二个类的位置``AuthenticationManager。这个类本身不包含验证逻辑,负责管理下面的 Provider 类。真正工作的类是
ProviderManager,他的authenticate方法是拿到 Provider,真正校验的逻辑是在 Provider 里面。
for (AuthenticationProvider provider : getProviders()) {`在这个地方,需要循环多个 Provider,哪个支持当前的登录就使用哪个,比如用户名密码登录,或者微信登录是使用不同的Provider 的。
具体的 Provider 是 DAOAUthenticationProvider。该类进行具体实现,该类的父类又调用了我们自己编写的 UserDetailService,这就和我们自定义代码集合起来了。
仔细跟进代码,成功后会调用我们自定义的成功代码,失败代码是在每个异常地方都会调用。
2、认证结果如何再多个请求之间共享
image
主要是 SecurityContent 和 SecurityContentHolder ,SecurityContentHolder是一个本地线程变量;最后一个过滤器是过滤器链的最前端,请求进入时候,检查 session 信息到线程,出去的时候把线程信息保存到 session。
3、获取用户认证信息
可以通过 SecurityContentHolder.getContext().getAuthentication()获得。
也可以在方法参数上直接使用
image
还可以使用部分信息
image
1.5 图像验证码
需要三个步骤:
1、根据随机数生成图片
2、随机数存入 session
3、图片写到接口响应
开始建立一个包,专门负责验证码,首先图像验证码的基本类。
public class ImageCode { private BufferedImage image;//图片 private String code;//验证码 private LocalDateTime expireTime;//过期时间 public BufferedImage getImage() { return image; } public void setImage(BufferedImage image) { this.image = image; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public LocalDateTime getExpireTime() { return expireTime; } public void setExpireTime(LocalDateTime expireTime) { this.expireTime = expireTime; } public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) { super(); this.image = image; this.code = code; this.expireTime = expireTime; } public ImageCode(BufferedImage image, String code, int expireInt) { super(); this.image = image; this.code = code; this.expireTime = LocalDateTime.now().plusSeconds(expireInt); } }
下面开始编写图像验证码的控制器
@RestControllerpublic class ValidateCodeController { private static final String SESSION_KEY="SESSION_KEY_IMAGE_CODE"; //工具类 private SessionStrategy sessionStrategy=new HttpSessionSessionStrategy(); @GetMapping("/image/code") public void createCode(HttpServletRequest request,HttpServletResponse response) throws IOException{ //1创建 ImageCode 对象 ImageCode imageCode=createImageCode(request); //2从 request 拿到 session,把imageCode存入到 SESSION_KEY 当中 sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, imageCode); //3输出到响应 ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream()); } private ImageCode createImageCode(HttpServletRequest request) { ...省略 return ...; } }
如何使得验证码在何时使用以及在哪里处理验证码过程。首先我们修改下登录页,添加图形验证码
image
展现的样子
image
然后,我们需要一个过滤器,构建ValidateCodeFilter
详细代码如下
//OncePerRequestFilter 保证过滤器每次只被调用一次public class ValidateCodeFilter extends OncePerRequestFilter{ private AuthenticationFailureHandler authenticationFailureHandler; private SessionStrategy sessionStrategy=new HttpSessionSessionStrategy(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //判断拦截的请求 URL,只是登录的 URL //并且是 POST 请求 if(StringUtils.equals("/userlogin", request.getRequestURI()) && StringUtils.equalsIgnoreCase("post", request.getMethod())){ try { validate(new ServletWebRequest(request)); } catch (ImageCodeException e) {//自定义异常进行捕获 //一旦出现异常,使用authenticationFailureHandler来处理 authenticationFailureHandler.onAuthenticationFailure(request, response, e); return; } }else{ filterChain.doFilter(request, response); } } private void validate(ServletWebRequest request) throws ImageCodeException, ServletRequestBindingException { //分别拿到 session 和请求里面的验证码信息 ImageCode codeInSession=(ImageCode)sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode"); if(StringUtils.isEmpty(codeInRequest)){ throw new ImageCodeException("验证码不能为空"); } if(codeInSession==null){ throw new ImageCodeException("验证码不存在"); } if(codeInSession.isExpire()){ sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY); throw new ImageCodeException("验证码过期"); } if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)){ throw new ImageCodeException("验证码不匹配"); } //清除 session 里面的 ImageCode sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY); } public AuthenticationFailureHandler getAuthenticationFailureHandler() { return authenticationFailureHandler; } public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { this.authenticationFailureHandler = authenticationFailureHandler; } public SessionStrategy getSessionStrategy() { return sessionStrategy; } public void setSessionStrategy(SessionStrategy sessionStrategy) { this.sessionStrategy = sessionStrategy; } }
之后需要把这个过滤器添加到过滤器链上
image
添加到UsernamePasswordAuthenticationFilter
过滤器之前。
1.6 图像验证码拦截URL 可配置
我们在ImageCodeProperties 里面添加一个 url 属性
image
这个是一个 url 集合,以逗号分隔的,形如
image
1.7 记住我功能实现
1、原理
image
(1)、用户超过登录之后,会调用一个叫记住我的服务,该服务调用 TokenRepository 生成 token,然后将 token 写入浏览器 Cookie 和保存到数据库;
(2)、第二天发起请求的时候,会结果一个叫记住我验证过滤器,他会从记住我服务里面读取token 信息,然后再调用 UserDetailService,完成登录;
其中记住我的过滤器在基本认证过滤器之后,如下图的位置。
image
2、实现
2.1 首先在登录页面增加记住我的 checkbox。
image
注意红色框的内容必须是这样。
然后配置 TokenRepository,在之前的 SS 配置类中进行,这里需要数据源注入
@Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository(){ JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl=new JdbcTokenRepositoryImpl(); jdbcTokenRepositoryImpl.setDataSource(dataSource); return jdbcTokenRepositoryImpl; } //为了后面获取用户信息使用 @Autowired private CustomerUserDetailService customerUserDetailService;
需要预先创建一个表
create table persistent_logins ( username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
2.2 配置记住我的过期时间
也是在WebProperties 里面增加一个属性
private int rememberMeSecond=3600;//set/get方法
2.3 配置记住我到 SS 配置
image
到此为止,配置记住我就完成了。
可以测试,成功登录后,数据库添加一条信息。
image
2、基于短信验证码的认证
2.1 开发短信验证码接口
基于图像验证码的接口改造一个发送短信验证码,在ValidateCodeController
里面增加一个短信验证码的方法
@Autowired private SMSSender smsSender;@GetMapping("/sms/code") public void createSMS(HttpServletRequest request,HttpServletResponse response) throws IOException, ServletRequestBindingException{ //1创建 ImageCode 对象 SMSCode smsCode=createSMSCode(new ServletWebRequest(request, response)); //2从 request 拿到 session,把imageCode存入到 SESSION_KEY 当中 sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY, smsCode); //3短信供应商发送 String mobile=ServletRequestUtils.getRequiredStringParameter(request, "mobile"); //这里模拟 smsSender.sendSMS(mobile, ""); } private SMSCode createSMSCode(ServletWebRequest servletWebRequest) { SMSCode code=new SMSCode("1234", 60000); return code; }
其中短信供应商也应该封装起来,这里粗略的封装下
public interface SMSSender { public void sendSMS(String phone,String code); }
实现
@Component("smsSender")public class DianxinSMSSender implements SMSSender{ @Override public void sendSMS(String phone, String code) { System.out.println("想手机"+phone+"发送验证码"+code); } }
便捷前端页面
<form name="f" action="/mobilelogin" method="POST"> <table> <tbody> <tr> <td>手机号:</td> <td><input type="text" name="mobile" value="13688888888"></td> </tr> <tr> <td>手机验证码:</td> <td><input name="smsCode"><a href="/sms/code?mobile=13688888888">发送验证码</a></td> </tr> <tr> <td colspan="2"><input name="submit" type="submit" value="登录"></td> </tr> </tbody> </table> </form>
2.2 校验短信验证码和登录
和用户密码登录类似构造自己的过滤器,但是短信验证码校验是放在过滤器之前,方便通用。
image
接下来开始逐个实现SMSCodeAuthenticationToken
模仿UsernamePasswordAuthenticationToken
代码,稍作修改
//封装登录信息public class SMSCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; // ~ Instance fields // ================================================================================================ private final Object principal;//未验证之前是手机号,验证之后是用户信息 //private Object credentials;//密码,由于在这之前已经验证过了,所以不需要这个属性 // ~ Constructors // =================================================================================================== /** * This constructor can be safely used by any code that wishes to create a * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()} * will return <code>false</code>. * */ public SMSCodeAuthenticationToken(String mobile) { super(null); this.principal = mobile; setAuthenticated(false); } /** * This constructor should only be used by <code>AuthenticationManager</code> or * <code>AuthenticationProvider</code> implementations that are satisfied with * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>) * authentication token. * * @param principal * @param credentials * @param authorities */ public SMSCodeAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; super.setAuthenticated(true); // must use super, as we override } // ~ Methods // ======================================================================================================== public Object getPrincipal() { return this.principal; } public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { if (isAuthenticated) { throw new IllegalArgumentException( "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); } super.setAuthenticated(false); } @Override public void eraseCredentials() { super.eraseCredentials(); } @Override public Object getCredentials() { return null; } }
SMSCodeAuthenticationFilter
同样模仿UsernamePasswordAuthenticationFilter
public class SMSCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String MOBILE = "mobile"; private String mobileParameter = MOBILE; private boolean postOnly = true; public SMSCodeAuthenticationFilter() { super(new AntPathRequestMatcher("/mobilelogin", "POST")); } public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String mobile = obtainMobile(request); if (mobile == null) { mobile = ""; } mobile = mobile.trim(); SMSCodeAuthenticationToken authRequest = new SMSCodeAuthenticationToken(mobile); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } protected String obtainMobile(HttpServletRequest request) { return request.getParameter(mobileParameter); } protected void setDetails(HttpServletRequest request, SMSCodeAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } public void setMobileParameter(String mobileParameter) { Assert.hasText(mobileParameter, "mobileParameter parameter must not be empty or null"); this.mobileParameter = mobileParameter; } public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } }
SMSCodeAuthenticationProvider
类的代码如下
public class SMSCodeAuthenticationProvider implements AuthenticationProvider{ private UserDetailsService userDetailsService; //使用 UserDetailService 获取用户信息重新组装 Authentication @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { SMSCodeAuthenticationToken token=(SMSCodeAuthenticationToken)authentication; UserDetails user=userDetailsService.loadUserByUsername((String)token.getPrincipal()); if(user==null){ throw new InternalAuthenticationServiceException("无法获取用户信息"); } SMSCodeAuthenticationToken result=new SMSCodeAuthenticationToken(user,user.getAuthorities()); result.setDetails(token.getDetails());//需要把之前的 Detail 设置到新的 Token 里面 return result; } //检查参数是不是我们定义的 SMSCodeAuthenticationToken @Override public boolean supports(Class<?> authentication) { return SMSCodeAuthenticationToken.class.isAssignableFrom(authentication); } public UserDetailsService getUserDetailsService() { return userDetailsService; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
最后SMSCodeFilter
雷同之前的图形验证码的过滤器,需要修改部分代码即可
//OncePerRequestFilter 保证过滤器每次只被调用一次public class SMSCodeFilter extends OncePerRequestFilter{ private AuthenticationFailureHandler authenticationFailureHandler; private SessionStrategy sessionStrategy=new HttpSessionSessionStrategy(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //判断拦截的请求 URL,只是登录的 URL //并且是 POST 请求 if(StringUtils.equals("/mobilelogin", request.getRequestURI()) && StringUtils.equalsIgnoreCase("post", request.getMethod())){ try { validate(new ServletWebRequest(request)); } catch (ImageCodeException e) {//自定义异常进行捕获 //一旦出现异常,使用authenticationFailureHandler来处理 authenticationFailureHandler.onAuthenticationFailure(request, response, e); return; } } filterChain.doFilter(request, response); } private void validate(ServletWebRequest request) throws ServletRequestBindingException { //分别拿到 session 和请求里面的验证码信息 SMSCode codeInSession=(SMSCode)sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY_SMS); String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode"); if(StringUtils.isEmpty(codeInRequest)){ throw new ImageCodeException("验证码不能为空"); } if(codeInSession==null){ throw new ImageCodeException("验证码不存在"); } if(codeInSession.isExpire()){ sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_SMS); throw new ImageCodeException("验证码过期"); } if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)){ throw new ImageCodeException("验证码不匹配"); } //清除 session 里面的 ImageCode sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY_SMS); } public AuthenticationFailureHandler getAuthenticationFailureHandler() { return authenticationFailureHandler; } public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { this.authenticationFailureHandler = authenticationFailureHandler; } public SessionStrategy getSessionStrategy() { return sessionStrategy; } public void setSessionStrategy(SessionStrategy sessionStrategy) { this.sessionStrategy = sessionStrategy; } }
最终配置以上代码,使其可以正常工作
分连部分,首先配置前三个,配到核心包里面
@Componentpublic class SMSCodeAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>{ @Autowired private AuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private AuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private UserDetailsService userDetailsService; @Override public void configure(HttpSecurity http) throws Exception { SMSCodeAuthenticationFilter sMSCodeAuthenticationFilter=new SMSCodeAuthenticationFilter(); sMSCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class)); sMSCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); sMSCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler); SMSCodeAuthenticationProvider sMSCodeAuthenticationProvider=new SMSCodeAuthenticationProvider(); sMSCodeAuthenticationProvider.setUserDetailsService(userDetailsService); http.authenticationProvider(sMSCodeAuthenticationProvider) .addFilterAfter(sMSCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } }
再把SMSCodeFilter
类似ValidateCodeFilter
在 SS 配置中进行
image
最后把SMSCodeAuthenticationConfig
也添加到 SS 配智中。
先引入,最后 apply(sMSCodeAuthenticationConfig);
最后贴出完整代码
@Configurationpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter{ @Autowired private SecurityProperties securityProperties; @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Autowired private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler; @Autowired private MyAuthenticationFailureHandler myAuthenticationFailureHandler; @Autowired private DataSource dataSource; @Bean public PersistentTokenRepository persistentTokenRepository(){ JdbcTokenRepositoryImpl jdbcTokenRepositoryImpl=new JdbcTokenRepositoryImpl(); jdbcTokenRepositoryImpl.setDataSource(dataSource); //jdbcTokenRepositoryImpl.setCreateTableOnStartup(true);//执行一次,创建表,也可以自行创建表 return jdbcTokenRepositoryImpl; } @Autowired private UserDetailsService userDetailsService; @Autowired private SMSCodeAuthenticationConfig sMSCodeAuthenticationConfig; @Override protected void configure(HttpSecurity http) throws Exception { System.out.println("Config"); ValidateCodeFilter validateCodeFilter=new ValidateCodeFilter(); validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); SMSCodeFilter smsCodeFilter=new SMSCodeFilter(); smsCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler); http .addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) .formLogin()//表单登录,身份认证 .loginPage("/authlogin")//设置登录页 .loginProcessingUrl("/userlogin")//设置表单提交请求的 URL .successHandler(myAuthenticationSuccessHandler) .failureHandler(myAuthenticationFailureHandler) .and() .rememberMe() .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(securityProperties.getWeb().getRememberMeSecond()) .userDetailsService(userDetailsService) .and() .authorizeRequests()//对请求授权 .antMatchers("/authlogin","/mobilelogin", "/login.html",//默认的登录页 "/image/code", "/sms/code", securityProperties.getWeb().getLoginPage()) .permitAll()//表示对这个 url 永远的通过 .anyRequest()//任何请求 .authenticated()//需要身份认证 .and() .csrf().disable()//把 csdf 跨站防护关闭 .apply(sMSCodeAuthenticationConfig); } }
作者:breezedancer
链接:https://www.jianshu.com/p/96e67ceb8fd8