2. SpringSecurity 自定义表单登录

在上一篇文章的结尾,我们列入了默认使用 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 接口

2-1

用户名任然是 user,密码是日志中输出的 password。如果我们输错了用户名、密码,会有如下提示

2-3

输入正确则可以访问到我们的接口资源。

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!

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值