[Security] 自定义表单登录

Posted by hblolj on 2019-07-15

在上一篇文章的结尾,我们列入了默认使用 SpringSecurity 一些待优化和解决的问题,我们再来回顾一下

  • 用户登录不可能以这种弹框形式去登录,一般网页都有自己的登录页面(自定义登录页面)
  • 用户名、密码应该是从数据库中读取,而不是默认和随机的(自定义认证逻辑)
  • 并不是对所有的资源或接口都需要认证(设置资源白名单)
  • 认证成功或者失败的处理,比如登录成功可以做一些记录,失败做一些处理

本篇文章就主要解决上面四点问题

自定义登录页面/登录地址

OK,首先第一点,让我们来解决一下,将默认的弹框登录方式改为网页表单登录方式。我们只需要在我们的项目中自定义一个 WebSecurityConfigurerAdapter 的实现类,并重写它的 configure(HttpSecurity http) 方法,在这个方法中我们显示指定登录方式为 formLogin (默认为 httpBasic ) 示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @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 登录,但是还是离我们的要求有一些差距

  • 登录页面虽然是表单登录了,但是是默认的。我们需要自定义的登录页面。
  • 在前后端分离的情况下,我们需要自定义登录接口地址,给 App 端使用。

我们此时分析一下,发现问题的核心不在于登录页面,也不在于登录接口。我们上面访问一个资源跳转到所谓的登录页面。实质上是系统判断我们没有认证,引导我们跳转到一个地址,这个地址既可以是一个 web 页面,也可以是一个 restful 接口。所以上面两个问题本质上是一个问题,就是配置系统的表单认证地址。具体实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* @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() 表示认证页面的访问不需要认证,否则会死循环导致重定向次数过多问题。这样我们就完美的解决了自定义登录页面与地址问题,第一个问题解决。

设置资源白名单

既然这里用到了 antMatcherpermitAll,那我们提前说一下第三个问题,资源白名单,这里要分情况讨论一下:

  • 前后端分离
    • 前端页面资源不在我们后端应用的管辖下,我们只需要管理好我们的接口访问权限即可。
  • 不分离
    • 前端页面放在应用文件夹下,那么就需要对对应的文件路径进行管理。

具体管理方式有两种,一种指定具体的访问地址,例如 .antMatchers("/page/login.html").permitAll() ,这里还可以使用 * 通配符进行范围指定

  • /page/*.html : page 下的所有 html

  • /page/** : page 下的所有资源

另一种方式是在自定义的 WebSecurityConfig 类中重写 configure(WebSecurity web) 方法,在方法中对静态资源设置不拦截,这里注意一下,spring boot 的默认静态资源放置位置是在 resource/static 下,可以在 static 下新建一个文件夹,然后在上述方法中指定跳过拦截的文件路径即可。

1
2
3
4
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/page/**");
}

到了这里,第三个问题基本上也解决了。那我们还剩下两个问题要处理,自定义认证逻辑与认证结果处理。我们按照业务顺序先来处理一下自定义认证逻辑。

自定义认证逻辑

如果让我们来设计认证流程,自定义认证逻辑我们可以分为三块

  • 从请求中获取用户认证信息,在表单认证这里就是用户名与密码
  • 按照认证信息从数据库查询取出用户信息
  • 对取出的用户信息与认证信息进行校验比对
    • 比对密码
    • 校验用户状态,比如账号是否是冻结的等等

如果使用 SpringSecurity 默认帮我们实现的表单认证逻辑,我们只需要实现第二步即可,具体步骤如下:

  • 自定义一个 UserDetailsService 的实现类,重写它的 loadUserByUsername 方法,在这个方法里面按参数到数据库中查询用户信息,最后返回一个 UserDetail 的实现类。示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /**
    * @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

        1
        2
        3
        4
        @Bean
        public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
        }

        我们注册时,可以使用该 PasswordEncoder 对用户的密码进行加密存储到数据库中,取出时,SpringSecurity 会从获取到该 passwordEncoder 来进行解密校验。我们自己模拟的时候,可以对密码进行加密返回。示例:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        /**
        * @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 也不是我们自己实现的,会出现密码校验不上

      • 解决方案,全局指定默认的 UserDetailServicePasswordEncoder

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        /**
        * @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 里面,框架帮我们封装了两个接口 ( AuthenticationFailureHandlerAuthenticationSuccessHandler ),我们只需要实现这两个接口,重写 ( onAuthenticationFailureonAuthenticationSuccess 方法) 并将其实现类配置到我们自定义的 WebSecurityConfig 即可使用。

  • 认证成功处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /**
    * @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());
    }
    }
  • 认证失败处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /**
    * @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());
    }
    }
  • 添加到配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    /**
    * @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")
    // 自定义表单登录的 action 地址,默认是 /login
    .loginProcessingUrl("/authentication/form")
    // 设置认证成功处理器与认证失败处理器
    .successHandler(authenticationSuccessHandler)
    .failureHandler(authenticationFailureHandler)
    .and()
    .authorizeRequests()
    // 允许登录页面不需要认证就可以访问,不然会死循环导致重定向次数过多
    .antMatchers("/page/login.html").permitAll()
    .anyRequest() // 对所有的请求
    .authenticated(); // 都进行认证
    // .and()
    // .exceptionHandling()
    // .authenticationEntryPoint(authenticationEntryPoint); // 实现了 EntryPoint 对 loginPage 有覆盖作用,loginPage 不生效
    }
    }

这里要注意几点,在我们的需求中可能会出现,比如登录前访问 A 页面,现在登陆后需要自动跳转到 A 页面。这里我们可以观察一下,AuthenticationSuccessHandlerAuthenticationFailureHandler 接口的实现类

  • 1
    SavedRequestAwareAuthenticationSuccessHandler
    • 继承该类,调用 super.onAuthenticationSuccess 方法,会跳转到认证前的页面
    1
    SimpleUrlAuthenticationFailureHandler
    • 继承该类,调用 super.onAuthenticationFailure 方法会跳转到设置的页面,如果没有设置会返回 401,同时可以指定 forwardredirect 方式

关于适配 web 与 app 方面,在处理方法中从请求中分析出客户端类型,然后做出对应的处理即可。比如是引导页面跳转,还是返回一段 JSON。


OK,到了这里,开头我们的几个目标问题都已经解决了,下一篇文章我们将给大家带了在 SpringSecurity 下自定义认证方式的实现说明(手机号登陆)。

To Be Continue…