Spring Security 实战

JPA管理用户表

JPA:Java Persistence API。JPA是一套规范,而Spring Boot JPA是实现JPA规范的一套产品。Spring Boot JPA默认使用Hibernate作为对象关系映射方案。使用Spring Boot JPA,用户往往只需要编写一个Repository接口,不接触具体的SQL语句,就可以操作关系型数据库了。

这里以MySQL数据库、使用Hikari连接池的技术方案为例,说明如何使用JPA管理用户表。其他JPA的操作可以参考官方文档,中文可以参考DDCH的JPA快速指南

导入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.27</version>
    <scope>runtime</scope>
</dependency>

导入两个依赖,一个是Spring Boot JPA的实现库,另一个是JDBC连接MySQL的驱动。JPA也需要使用JDBC驱动来连接MySQL数据库。

另外,为了方便数据类(实体类)的处理,还需要引入Lombok依赖,非常方便好用。

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

配置数据库

spring:
    datasource:
        url: jdbc:mysql://localhost:3306/test_medicine_manage?useUnicode=true&character_set_server=utf8mb4
        username: root
        password: root
        driver-class-name: com.mysql.cj.jdbc.Driver
    jpa:
        hibernate:
            ddl-auto: update
        show-sql: true

配置Spring的DataSource和JPA。

datasource部分配置数据库的相关信息,数据库的连接地址、用户名、密码,以及驱动名称(可选)。jpa部分配置hibernate和日志即可。其中比较重要的配置时ddl-auto选项。这里定义了每次应用启动和关闭时要对数据库进行的前置和后置操作。例如:create表示,每次数据库启动时删除数据库中原有的表重新新建表;create-drop表示,应用启动时新建表,应用退出时删除表。这两种都可以方便用于测试。update则表示,应用启动和退出时不删除原有数据,而是根据情况对表进行更新(例如加入新列、删除列、修改列等),用于生产环境。

编写实体类

使用JPA时,实体类的编写是最重要的部分。需要借助实体类上的注解,让JPA理解这个实体类对应的数据库表应该是什么样子,操作的时候有哪些规范。编写实体类的过程,就对应于在数据库中创建表的过程。

以下是这次实战中用到的User和Role两个数据实体类。

package cn.ustcsoc.medicinemanage.model.po.user;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;

@Setter @Getter @Entity @AllArgsConstructor @NoArgsConstructor
public class User {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String username;

    private String name;

    private String password;

    @ManyToMany(fetch = FetchType.EAGER)
    private Collection<Role> roles = new ArrayList<>();
}
package cn.ustcsoc.medicinemanage.model.po.user;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity @Getter @Setter @AllArgsConstructor @NoArgsConstructor
public class Role {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;
}

对其中的一些注解进行一些解释:

  • Entity:让JPA知道这是一个实体类,对应数据库表
  • Getter Setter:Lombok的注解,自动生成get和set方法
  • AllArgsConstructor NoArgsConstructor:Lombok注解,生成全参构造和无参构造方法
  • Id:jpa注解,表示该属性是主键
  • GeneratedValue:jpa注解,表示该属性自动填充。java中存入之前可以不赋值,存入数据库时自动生成。
  • ManyToMany:表示多对多关系。还有OneToOne、OneToMany、ManyToOne

编写Repository接口

使用Spring Boot JPA用户只需要编写Repository接口,Spring Boot会提供一套默认实现。编写Repository接口的过程类似于编写SQL查询语句,调用接口的方法就类似执行查询语句。

Spring Security配置

package cn.ustcsoc.medicinemanage.config;

import cn.ustcsoc.medicinemanage.config.filter.JWTAuthenticationFilter;
import cn.ustcsoc.medicinemanage.config.filter.JWTAuthorizationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JWTAuthenticationFilter customAuthenticationFilter = new JWTAuthenticationFilter();
        customAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
        
        http
                .csrf()
                .disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilter(customAuthenticationFilter)
                .addFilterBefore(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                .antMatchers(HttpMethod.GET, "/user/**").hasAnyAuthority("ROLE_USER")
                .antMatchers(HttpMethod.POST, "/user/save/**").hasAnyAuthority("ROLE_ADMIN")
                .antMatchers(HttpMethod.GET, "/users").hasAnyAuthority("ROLE_ADMIN")
                .anyRequest().denyAll();
    }
}

其中JWTAuthenticationFilter和JWTAuthorizationFilter是自定义的Filter,后面会详细介绍。

关键注解:

  • Configuration 告诉Spring这是一个配置类,类中的Bean将被扫描和创建,加入Spring容器中管理。
  • EnableWebSecurity告诉Spring启用Spring Boot Security。
  • EnableGlobalMethodSecurity用来开启方法级别的访问过滤。
  • RequiredArgsConstructor:Lombok注解,用来自动生成构造方法,传入所需的参数。

authenticationManagerBean()方法是调用父类的,作用是拿到当前的authenticationManager对象,协助进行认证。

http部分是进行安全配置的核心。这里需要注意的几个地方是:

  • csrf().disable() 表示关闭Spring Security的CSRF防御。因为使用JWT,不是基于cookie进行身份认证,已经可以杜绝csrf。所以不需要开启csrf防御。
  • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 表示让Spring Security不创建和使用Session。因为是基于JWT的认证和授权,因此没有必要使用Session。
  • addFilter() 可以默认在当前的Filter链最后添加一个自定义Filter。
  • addFilterBefore() 可以在指定的Filter类之前添加一个新的Filter。
  • authorizeRequests() 后面指定通过了前面Filter的请求将如何过滤。后面可以通过antMatchers来添加对各个情况的处理方案。最后anyRequest().denyAll()来指定所有不符合匹配的其他请求都应该被拒绝。

可以看到,一个请求到达后端后,先通过各个Filter,再到达Spring Security这里进行情况匹配,根据此次请求获得的权限进行判断,最后通过所有过滤的请求才能到达DispatcherSevlet,然后分发给Controller进行处理。

JWT相关Filter

认证

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final Environment environment;

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // create jwt for the user
        User user = (User) authResult.getPrincipal();
        Algorithm algorithm = Algorithm.HMAC256(environment.getProperty("jwt.secret","default-secret").getBytes());
        String accessToken = JWT.create()
                .withSubject(user.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
                .withIssuer(request.getRequestURL().toString())
                .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
                .sign(algorithm);
        response.setHeader("token", accessToken);
    }
}

该Filter最终继承自AbstractAuthenticationProcessingFilter,这个抽象的认证Filter定义了一个模板方法,我们只需要修改其中的步骤,就可以构建自定义的认证Filter。其中方法各个步骤的大致逻辑是,doFilter方法中先检查该请求是否是登录请求(默认/login路径),如果不是就直接跳过认证。如果是登录请求,就调用attempAuthentication方法,如果返回Null,说明认证失败,调用unsuccessfulAuthentication,进行登录失败的处理;否则调用successfulAuthentication,进行登录成功的处理(如发放JWT访问密钥)。

其中attempAuthentication会根据环境选择合适的验证方法,例如如果这里继承的是UsernamePasswordAuthenticationFilter,就会选择通过UserDetailsService获得用户的用户名、密码等信息,和请求中的用户名密码进行比对,决定是否登录成功。

在JwtAuthenticationFilter中,如果用户登录成功,就会从配置文件中取得加密的secret,然后将用户名、有效期、角色等信息拼合成json串,最后用算法进行加密最后一部分,得到jwt。将jwt设置给response的header,请求就会自动返回了(如果不手动调用下一步的Filter.doFilger()方法,请求处理就会到此结束,Spring会自动将应答报文发回)

授权

@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
    private final Environment environment;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // if it is a login request, skip to login filter.
        if (request.getServletPath().equals("/api/login")) {
            filterChain.doFilter(request, response);
        } else {
            String token = request.getHeader(HttpHeaders.AUTHORIZATION);
            if (token != null) {
                try {
                    // verify jwt
                    Algorithm algorithm = Algorithm.HMAC256(environment.getProperty("jwt.secret", "default-secret").getBytes());
                    JWTVerifier verifier = JWT.require(algorithm).build();
                    DecodedJWT decodedJWT = verifier.verify(token);
                    // if no error, extract info from jwt
                    String username = decodedJWT.getSubject();
                    String[] roles = decodedJWT.getClaim("roles").asArray(String.class);
                    Date expiresAt = decodedJWT.getExpiresAt();
                    log.info("User {} JWT OK, will expire at {}", username, expiresAt.toString());
                    Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
                    Arrays.stream(roles).forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
                    // create authentication token
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, authorities);
                    // This means web application has completed authorization and actually given the right to THIS request
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                } catch (Exception e) {
                    
                } finally {
                    filterChain.doFilter(request, response);
                }
            } else {
                // if there is no jwt, no authorization for guest
                filterChain.doFilter(request, response);
            }
        }
    }
}

授权应该放在认证之前,因为如果请求包含有效的授权信息,则不需要再进行认证。

授权中的逻辑是,首先检查请求是不是登录请求。如果是登录请求(默认以/login作为路径)则直接跳过授权步骤,直接进入认证过程,对用户名密码进行匹配检查。

接下来,授权Filter会尝试取得请求中的jwt。无论是请求没有jwt还是jwt无效,Filter都不会授予任何权限,直接进入下一步。

如果jwt有效,授权Filter会查询该用户拥有那些权限,将这些权限信息放在authenticationToken中,并设置在本次请求的context中。authenticationToken就是系统内部的令牌,如果拿到authenticationToken,后续请求的处理步骤就可以根据其中说明的权限予以放行。

拓展

一些时候,为了更好的安全措施,系统发放的jwt实际有校期很短,以免被人为复制盗用(注意JWT验证方式的特性,请求是无连接的,每次只会根据JWT验证请求是否有权限访问内容,因此如果JWT被盗用到其他浏览器,用户身份就可能被冒充)。但是过期太快也会导致用户需要频繁登录以刷新认证,并不方便使用。

因此,一些系统中就引入了refreshToken。refreshToken也是采用和jwt同样的方式产生和验证,但是不同的是,refreshToken不会直接参与授权,而是当浏览器发现jwt过期时,可以发送带有refreshToken的请求来要求后端直接提供新的jwt。因此refreshToken的有效期通常比jwt要长很多。这样,就算jwt被盗用,由于其短期快速过期的特点,也不容易造成太大损失。