记在一次项目中学习与应用的 SpringSecurity

概述

在我们编写一个项目的同时,除了基础的业务代码以外,最重中之重的便是权限设计相关的事物了。像以往的项目中我只使用了比较简单的 JWT 和注解去配合鉴权。这样是非常简陋的,在企业级的项目之中,我们必须要围绕着权限去深入的进行设计。所以此处要立一个 flag,接下来要好好研究RBAC权限模型

如题,此次在一次项目中应用了 SprintSecurity安全框架,此次项目关于令牌方面,依然使用了 JWT ,相关信息可以参考之前的博文: Json Web Tokn 配合拦截器 / 自定义注解 实现鉴定权限

配置

pom

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.5.0</version>
    </dependency>

使用

当我们引入相应的配置文件之后,我们就需要编写它的配置类了。首先我们创建一个名为「SecurityConfig」的配置类,并且将其继承与「WebSecurityConfigurerAdapter」类,含义为此类是 SpringSecur 的配置类。
并在此类之上加入注解「@EnableWebSecurity」。意思是启动安全框架。

其后,我们实现了两个名为「configure」的方法,但他们的参数并不相同,参数名为「HttpSecurity」的方法便是我们主要配置的地方。我们可以在此配置有哪些接口需要被保护,又或者加入那些拦截器去拦截校验。

而参数名为「AuthenticationManagerBuilder」的方法则是配置身份验证管理器 的。

如下列代码所示,我们在配置的方法中写入了一些命令。
其实很简单,「authorizeRequests()」方法代表接下来要编写接口的权限设置,使用「antMatchers()」方法添加了一个地址(/index/**),然后跟上了「permitAll()」方法。代表只要是以「/index」开头的路径,全部都允许直接访问。

而紧跟其后写了「anyRequest()」方法和「authenticated()」方法,代表在前面允许 index 路径其后的所有地址都要通过权限校验才可以!
也就是必须要登录之后~

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .antMatchers(
                    "/index/**"
            ).permitAll()
            .anyRequest().authenticated();
}

这样便是一个基础的配置文件了。这时我们就发现了一个问题,通过配置文件我们知道,除了以 index 开头的地址我们都需要拥有访问权限才能访问,那么我们应该如何去获得权限呢?

同时也衍生了一个子问题——我们如何去判断权限呢?

所以这里我们就需要配置一些过滤器,为我们的用户授权。

授权

UsernamePasswordAuthenticationFilter

我们都明白,在一般的项目中我们想拥有权限,很显然是需要我们去登陆,只有登陆过后系统才知道我们的账户是否拥有权限,所以很显然,我们第一个需要配置的过滤器便是关于验证「用户名与密码拦截器」.

我们此时便创建了一个名称为「JWTAuthenticationFilter」的类,它继承与「UsernamePasswordAuthenticationFilter」类,并实现了三个方法,分别是:

  • attemptAuthentication 负责身份验证
  • successfulAuthentication 负责成功后的动作
  • unsuccessfulAuthentication 负责失败的动作

很显然,我们需要声明那些地址会被我们的拦截器所拦截到,其次我们再去实现这三个方法。

因此我们创建了一个构造方法来声明拦截的目标地址(即登录的接口地址),此时我们在构造方法中引入了一个authenticationManager「身份验证管理器」,具体作用在下面会讲述。

public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {       
    this.authenticationManager = authenticationManager;        
    super.setFilterProcessesUrl("/auth/login"); 
}

紧接其后我们去编写attemptAuthentication(身份验证)方法。我们首先需要从「servletRequest」方法中将前端请求的数据拿出来。不过第一步需要将这个参数强转为「HttpServletRequest」类型我们才可以去操作。

    BufferedReader streamReader = new BufferedReader(new InputStreamReader(request.getInputStream(), "UTF-8"));
    StringBuilder responseStrBuilder = new StringBuilder();
    String inputStr;
    while ((inputStr = streamReader.readLine()) != null){
        responseStrBuilder.append(inputStr);
    }
    JSONObject jsonObject = JSONObject.parseObject(responseStrBuilder.toString());

当我们已经能够获取到前端的 JSON 数据之时,我们就需要去想,我们该如何判断账号密码呢?去引入调用 Service 之中的方法吗?

一般我们都是借助 SpringSecurity 来帮助我们完成这一步。

实际上写到这一步之时,我也在思考使用自带的登录校验方式是否是多此一举,这样去写的好处有哪些,自己编写登录逻辑的坏处有哪些,最终得到的答案是,可能这样有些多此一举。因为我们完全可以自己编写一个登录接口然后让 SpringSecurity 去放行此接口,之后校验完毕之后再将其放行。而且我们此时学习的「获得用户权限」的操作也是直接从 JWT 中去取出来,然后赋予此用户。就算我们使用的是 session 的方式去操作,也无非是从 session 中存储的数据中去取出对应的权限。所以此处有些不解,还待继续学习。

此时我们直接返回下列代码。这个时候我们就用到了我们在构造方法中传递的「身份验证管理器」,我们使用了它的验证方法,并且

return authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(username, password));

可是它是如何知道我们的用户表的呢??? 我们没告诉它呀..
可以看到,我们使用的是 authenticationManager 中的验证方法,所以我们需要去完成一下查询这个用户的实体类的方法。

UserDetailsService

我们创建了一个名为「MyUserDetailsService」的类,它实现了「UserDetailsService」接口。并且实现了它下面一个名为 「loadUserByUsername」的方法。此方法的含义是通过用户的名称去数据库中查询我们的用户名和权限表,然后返回这个用户的信息。

在此我们应该就知道了,前面我们在「用户名与密码拦截器」中的验证方法中,我们调用「身份管理器」中的验证方法,也就是说,那个验证方法其实是调用实现了「UserDetailsService」接口的类,然后通过「loadUserByUsername」来获取前端传递过来的用户名对应的用户信息,然后让两者进行对比密码。

此时思路就很明显了,只是有些细节部分。我们通过 UserService中的 findByUserName 方法去获取此用户的实体类。值得注意的是,我们不同人编写的用户实体类必然不相同,所以我们还需要将其转换成符合 SpringSecurity 规范的实体类,也就是下列代码返回的「UserDetails」。

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    TwUser twUser = twUserService.findByUsername(s);
    if (twUser == null) {
        throw new UsernameNotFoundException("用户不存在");
    }
    //获取用户角色集
    return new MyUserDetails(twUser,twUserRoleService.findRoleByUserId(twUser.getId()));
}

通过代码我们可以发现,相对于我们的实体类来说,我们还从「twUserRoleService(用户角色表)」中取出了所有用户对应的角色。下图为角色的设计表,很显然我们是通过 UserId 去取出来的。
Snipaste_2021-02-04_17-03-58.png

如前所述,我们实现了「UserDetails」,名为「MyUserDetails」。它最重要的区别就是拥有一个名为「authorities」的权限集合,我们在其构造方法中将我们从数据库中获取的权限添加进去。

public class MyUserDetails implements UserDetails {
    private String id;
    private String username;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;

    public MyUserDetails(TwUser twUser,List<TwRole> roles) {
        this.id = twUser.getId();
        this.username = twUser.getUsername();
        this.password = twUser.getPassword();
        List<SimpleGrantedAuthority> authorities=new ArrayList<>(roles.size());
        for (TwRole role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
        }
        this.authorities = authorities;

    }

自此,我们的查询用户的操作类就编写完毕了,我们此时要在「SecurityConfig」类中把这个类交给「身份管理器」。

首先我们需要在「SecurityConfig」类中创建一个「身份管理器」。值得注意的是,我们需要把它注册为 bean,因为 SpringSecurity 会用到它。

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

然后再将我们刚才编写的 Service 注入进来。

@Autowired
@Qualifier("myUserDetailsService")
private UserDetailsService userDetailsService;

在这之后,你应该还记得我们之前的两个 Configure 方法,其中我们说了,参数为「AuthenticationManagerBuilder」的是身份管理器的生成器。对,我们要在此方法中配置这个操作类。

但是当前有一个问题就是,我们数据库之中不可能以明文形式去存储用户的密码,这样太不安全了。所以我们一般使用一些特殊的加密方式,然而对比用户密码与数据库中的加密密码这一步就需要我们告诉 SpringSecu 我们的加密方式了!

我采用了 SpringSecurity 内置的一个加密类,首先我们需要将它注入进来。

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder();
}

然后编写一下代码即可。

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}

登录成功与登录失败的处理事件

此时逻辑就是很是简单了。像成功事件,我们只需要根据用户的信息然后创建一个 token 即可,而失败的事件便是回复他————你的用户名或密码错误!

登录成功事件。

@Override
protected void successfulAuthentication(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain chain,
                                        Authentication authResult) throws IOException {

    MyUserDetails user = (MyUserDetails) authResult.getPrincipal();
    // 此处只写入了一个角色,并非角色集合,可改正!
    String role = "";
    Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
    for (GrantedAuthority authority : authorities) {
        role = authority.getAuthority();
    }
    String username = user.getUsername();
    String userId = user.getId();
    String token = JwtUtils.createToken(userId,username, role);

    JSONObject jsonObject = new JSONObject();
    jsonObject.put("userName",username);
    jsonObject.put("date",new Date());
    logService.addLog(jsonObject,true);
    String success = ResultUtils.success(new ResultLoginSuccess(username, role, token, "10002460607"));
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json; charset=utf-8");
    response.getWriter().write(success);
}

登录失败事件

@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json; charset=utf-8");
    response.getWriter().write(JSON.toJSONString(ResultUtils.fail("用户名或密码错误。")));
}

鉴权

上述我们阐述了授权的过程,那么下面便是鉴权过程。其实很简单,因为我们前面当用户登录成功之后,我们在 JWT 中写入了对应的角色集合(权限集合),所以我们下面只需要从 JWT 中获取此集合,然后再告诉SpringSec 就可以了。

很显然,我们又需要创建一个鉴权过滤器。

BasicAuthenticationFilter

我们创建了一个继承于「BasicAuthenticationFilter」名为「JWTAuthorizationFilter」的类。并且实现了名为「doFilterInternal」的方法,其实就是处理此过滤器事件的方法。

逻辑很简单,一般的前端在请求我们后端的接口之时,会把我们之前授权时创建的 token 添加到请求头,我们只需要获取这个请求头就可以了。如果 token 是空的,我们就给它放行,但值得注意的是,此放行非彼放行,其实是因为后面有「无权限处理事件」等着它,而不是让其访问接口。

@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain chain) throws IOException, ServletException {

    String tokenHeader = request.getHeader(JwtUtils.TOKEN_HEADER);
    if (tokenHeader == null || !JwtUtils.verity(tokenHeader)) {
        chain.doFilter(request, response);
        return;
    }
    // 此处我们创建了一个「getAuthentication」方法,其实就是把令牌中的字符串形式的角色名称转换为 SpringSecurity 所能认识的权限集合而已。
    SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
    super.doFilterInternal(request, response, chain);
}

private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
    String username = JwtUtils.getUsername(tokenHeader);
    String role = JwtUtils.getUserRole(tokenHeader);

    if (username != null){
        return new UsernamePasswordAuthenticationToken(username, null,
                Collections.singleton(new SimpleGrantedAuthority(role))
        );
    }
    return null;
}

AuthenticationEntryPoint

前面说了,当权限不足或token 失效会将其放行,然后进入「无权限处理事件」,所以我们创建了一个实现了「AuthenticationEntryPoint」接口,名为「JWTAuthenticationEntryPoint」的类,并实现了它的「commence」方法,这个方法就是对应的处理方法。

我们只需要通过「HttpServletResponse」返回前端信息就好。

    
public void commence(HttpServletRequest request,
                     HttpServletResponse response,
                     AuthenticationException authException) throws IOException, ServletException {
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json; charset=utf-8");
    response.getWriter().write(new ObjectMapper().writeValueAsString(ResultUtils.noPermission(authException.getMessage())));
}

组合

上述中,我们创建了各个拦截器和各种事件的处理类,最后我们需要将这些东西组合进入 SpringSecurity 之中。

我们把名称为「configure」并且参数是「HttpSecurity」这个方法更改为如下,便成功配置了。

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .antMatchers(
                    "/index/**"
            ).permitAll()
            .anyRequest().authenticated().
            and()
               //授权拦截器
            .addFilter(new JWTAuthenticationFilter(authenticationManager()))
            //鉴权拦截器
            .addFilter(new JWTAuthorizationFilter(authenticationManager()))
            //因为我们使用了 JWT,所以我们在此关闭了Session。
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            //异常处理,后面加上我们的「无权限的处理器」
            .exceptionHandling()
            .authenticationEntryPoint(new JWTAuthenticationEntryPoint());

}

最后

在学习这个框架之时,脑海中也会去像对应的功能应该如何实现,越想越觉得自己只是学会了基础的使用,还待深入学习。因为本博文完全靠网络查阅资料和自己的理解,可能有些部分有失偏颇,望见谅!