在上一篇文章的结尾,我们列入了默认使用 SpringSecurity 一些待优化和解决的问题,我们再来回顾一下
本篇文章就主要解决上面四点问题
OK,首先第一点,让我们来解决一下,将默认的弹框登录方式改为网页表单登录方式。我们只需要在我们的项目中自定义一个 WebSecurityConfigurerAdapter 的实现类,并重写它的 configure(HttpSecurity http) 方法,在这个方法中我们显示指定登录方式为 formLogin (默认为 httpBasic) 示例如下:
/**
* @author: hblolj
* @Date: 2019/3/14 10:07
* @Description:
* @Version:
**/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 指定登录认证方式为表单登录
.and()
.authorizeRequests()
.anyRequest() // 对所有的请求
.authenticated(); // 都进行认证
}
}
然后,重新启动应用,再次访问 http://localhost:8080/security/hello 接口
用户名任然是 user,密码是日志中输出的 password。如果我们输错了用户名、密码,会有如下提示
输入正确则可以访问到我们的接口资源。
OK,到目前为止我们将认证方式从 HttpBasic 转变为了 FormLogin 登录,但是还是离我们的要求有一些差距
我们此时分析一下,发现问题的核心不在于登录页面,也不在于登录接口。我们上面访问一个资源跳转到所谓的登录页面。实质上是系统判断我们没有认证,引导我们跳转到一个地址,这个地址既可以是一个 web 页面,也可以是一个 restful 接口。所以上面两个问题本质上是一个问题,就是配置系统的表单认证地址。具体实例如下:
/**
* @author: hblolj
* @Date: 2019/3/14 10:07
* @Description:
* @Version:
**/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 指定登录认证方式为表单登录
//指定自定义登录页面地址,一般前后端分离,这里就用不到了
.loginPage("/page/login.html")
// 自定义表单登录的 action 地址,默认是 /login
.loginProcessingUrl("/authentication/form")
.and()
.authorizeRequests()
// 允许登录页面不需要认证就可以访问,不然会死循环导致重定向次数过多
.antMatchers("/page/login.html").permitAll()
.anyRequest() // 对所有的请求
.authenticated() // 都进行认证
.and()
.csrf()
.disable(); // 关闭 csrf 防护
}
}
这里我们注意,loginPage 指定认证页面地址,loginProcessingUrl 指定认证地址,两者只需要配置一个即可,如果都配置了,则只有 loginProcessingUrl 生效。
这里我们可能会遇到一个需求,我们的后端应用同时给 Web 页面与 App 提供服务,这样他们的认证引导方式不一样,该怎么解决。我们要注意的是我们可以在 loginProcessingUrl 配置的接口里通过对请求的判断来动态对 web 和 app 请求进行定制化处理。
另外,如果配置的是 loginPage,则需要设置 .antMatchers("/page/login.html").permitAll() 表示认证页面的访问不需要认证,否则会死循环导致重定向次数过多问题。这样我们就完美的解决了自定义登录页面与地址问题,第一个问题解决。
既然这里用到了 antMatcher 与 permitAll,那我们提前说一下第三个问题,资源白名单,这里要分情况讨论一下:
具体管理方式有两种,一种指定具体的访问地址,例如.antMatchers("/page/login.html").permitAll()
,这里还可以使用 * 通配符进行范围指定
/page/*.html
: page 下的所有 html
/page/**
: page 下的所有资源
另一种方式是在自定义的 WebSecurityConfig 类中重写 configure(WebSecurity web)
方法,在方法中对静态资源设置不拦截,这里注意一下,spring boot 的默认静态资源放置位置是在 resource/static 下,可以在 static 下新建一个文件夹,然后在上述方法中指定跳过拦截的文件路径即可。
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/page/**");
}
到了这里,第三个问题基本上也解决了。那我们还剩下两个问题要处理,自定义认证逻辑与认证结果处理。我们按照业务顺序先来处理一下自定义认证逻辑。
自定义认证逻辑我们可以分为三块
如果使用 SpringSecurity 默认帮我们实现的表单认证逻辑,我们只需要实现第二步即可,具体步骤如下:
自定义一个 UserDetailsService 的实现类,重写它的 loadUserByUsername 方法,在这个方法里面按参数到数据库中查询用户信息,最后返回一个 UserDetail 的实现类。示例如下:
/**
* @author: hblolj
* @Date: 2019/3/14 10:40
* @Description:
* @Version:
**/
@Component
public class FormUserDetailService implements UserDetailsService{
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// TODO: 2019/3/14 按参数 s 从数据库查找用户信息,一般注入 dao 查询
// 返回的是 org.springframework.security.core.userdetails 下 User 类
// 在实际业务时,可以使系统的 User 类去实现 UserDetail 接口,然后返回自己的 User 类即可
// 构造方法传入的三个参数分别是用户名、密码、权限集合
// 还有另外一个构造方法,可以传额外四个参数,表示账号状态(启用、冻结、锁定等)
// 如果密码使用了加密,从数据库中取出的应该是加密过的密码,不是明文
return new User(s, "123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
这样,当我们使用表单登录时就会使用我们自定义的逻辑了(默认使用的其实是 InMemoryUserDetailsManager 这个类)。
这里有几点注意说明一下
在用户注册时对用户密码使用了加密时的处理。
SpringSecurity 给我们提供了 PasswordEncoder 来加密密码,我们可以制定一种加密类型,然后放入 IOC 容器中,加密解密使用这个共享的 PasswordEncoder。
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
我们注册时,可以使用该 PasswordEncoder 对用户的密码进行加密存储到数据库中,取出时,SpringSecurity 会从获取到该 passwordEncoder 来进行解密校验。我们自己模拟的时候,可以对密码进行加密返回。示例:
/**
* @author: hblolj
* @Date: 2019/3/14 10:40
* @Description:
* @Version:
**/
@Component
public class FormUserDetailService implements UserDetailsService{
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 模拟从数据库中取出的密码是已经加密过的密码
String password = passwordEncoder.encode("123");
User user = new User(s, password, true, true, true,
true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
return user;
}
}
当自己定义了多个 UserDetailsService 的实现类放到 IOC 容器时,会发现默认的 formLogin 会使用 InMemoryUserDetailsManager 的实现来处理校验逻辑。同时 SpringScurity 使用的 PasswordEncoder 也不是我们自己实现的,会出现密码校验不上
解决方案,全局指定默认的 UserDetailService 与 PasswordEncoder
/**
* @author: hblolj
* @Date: 2019/3/15 18:12
* @Description: 指定全局默认的 UserDetailService 与 PasswordEncoder
* @Version:
**/
@Configuration
public class GlobalAuthenticationConfigurer extends GlobalAuthenticationConfigurerAdapter {
private final UserDetailsService userService;
private final PasswordEncoder passwordEncoder;
@Autowired
public GlobalAuthenticationConfigurer(@Qualifier("formUserDetailService") UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
this.userService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder);
}
}
系统提供的 formLogin 不能满足我们的需求,需要自定义认证方式,比如短信验证码登录、微信登录等等。
经过前面的认证,现在会有两个结果,认证成功与认证失败。我们需求往往会要求我们正在这时做出对应的处理,比如记录信息、引导用户,返回用户信息等等。在 SpringSecurity 里面,框架帮我们封装了两个接口(AuthenticationFailureHandler 与 AuthenticationSuccessHandler),我们只需要实现这两个接口,重写 (onAuthenticationFailure 与 onAuthenticationSuccess 方法) 并将其实现类配置到我们自定义的 WebSecurityConfig 即可使用。
认证成功处理
/**
* @author: hblolj
* @Date: 2019/3/14 14:56
* @Description:
* @Version:
**/
@Slf4j
@Component
public class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
Authentication authentication) throws IOException, ServletException {
log.info("Login Success!");
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(authentication.getPrincipal().toString());
}
}
认证失败处理
/**
* @author: hblolj
* @Date: 2019/3/14 14:56
* @Description:
* @Version:
**/
@Slf4j
@Component
public class DefaultAuthenticationFailureHandler implements AuthenticationFailureHandler{
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
AuthenticationException e) throws IOException, ServletException {
// 自定义登录失败处理逻辑
log.info("Login Failure!");
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().write(e.getMessage());
}
}
添加到配置
/**
* @author: hblolj
* @Date: 2019/3/14 10:07
* @Description:
* @Version:
**/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 指定登录认证方式为表单登录
// .loginPage("http://www.baidu.com") //指定自定义登录页面地址,一般前后端分离,这里就用不到了
.loginProcessingUrl("/authentication/form") // 自定义表单登录的 action 地址,默认是 /login
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.authorizeRequests()
.antMatchers("/page/login.html").permitAll() // 允许登录页面不需要认证就可以访问,不然会死循环导致重定向次数过多
.anyRequest() // 对所有的请求
.authenticated(); // 都进行认证
// .and()
// .exceptionHandling()
// .authenticationEntryPoint(authenticationEntryPoint); // 实现了 EntryPoint 对 loginPage 有覆盖作用,loginPage 不生效
}
}
这里要注意几点,在我们的需求中可能会出现,比如登录前访问 A 页面,现在登陆后需要自动跳转到 A 页面。这里我们可以观察一下,AuthenticationSuccessHandler 与 AuthenticationFailureHandler 接口的实现类
SavedRequestAwareAuthenticationSuccessHandler
SimpleUrlAuthenticationFailureHandler
关于适配 Web 与 App 方面,在处理方法中从请求中分析出客户端类型,然后做出对应的处理即可。比如是引导页面跳转,还是返回一段 JSON。
OK,到了这里,开头我们的几个目标问题都已经解决了,下一篇文章我们将给大家带了在 SpringSecurity 下自定义认证方式的实现说明(手机号登陆),To Be Continue!