在上一篇文章的结尾,我们列入了默认使用 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 不能满足我们的需求,需要自定义认证方式,比如短信验证码登录、微信登录等等。
- 下一章节会示例,To Be Continue…
-
认证结果自定义处理
经过前面的认证,现在会有两个结果,认证成功与认证失败。我们需求往往会要求我们正在这时做出对应的处理,比如记录信息、引导用户,返回用户信息等等。在 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
- 继承该类,调用 super.onAuthenticationSuccess 方法,会跳转到认证前的页面
SimpleUrlAuthenticationFailureHandler
- 继承该类,调用 super.onAuthenticationFailure 方法会跳转到设置的页面,如果没有设置会返回 401,同时可以指定 forward 与 redirect 方式
关于适配 Web 与 App 方面,在处理方法中从请求中分析出客户端类型,然后做出对应的处理即可。比如是引导页面跳转,还是返回一段 JSON。
OK,到了这里,开头我们的几个目标问题都已经解决了,下一篇文章我们将给大家带了在 SpringSecurity 下自定义认证方式的实现说明(手机号登陆),To Be Continue!