Claws Garden

spring security jwt jpa实战

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快速指南

导入依赖

 1<dependency>
 2    <groupId>org.springframework.boot</groupId>
 3    <artifactId>spring-boot-starter-data-jpa</artifactId>
 4</dependency>
 5<dependency>
 6    <groupId>mysql</groupId>
 7    <artifactId>mysql-connector-java</artifactId>
 8    <version>8.0.27</version>
 9    <scope>runtime</scope>
10</dependency>

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

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

1<dependency>
2    <groupId>org.projectlombok</groupId>
3    <artifactId>lombok</artifactId>
4    <optional>true</optional>
5</dependency>

配置数据库

 1spring:
 2    datasource:
 3        url: jdbc:mysql://localhost:3306/test_medicine_manage?useUnicode=true&character_set_server=utf8mb4
 4        username: root
 5        password: root
 6        driver-class-name: com.mysql.cj.jdbc.Driver
 7    jpa:
 8        hibernate:
 9            ddl-auto: update
10        show-sql: true

配置Spring的DataSource和JPA。

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

编写实体类

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

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

 1package cn.ustcsoc.medicinemanage.model.po.user;
 2
 3import lombok.AllArgsConstructor;
 4import lombok.Getter;
 5import lombok.NoArgsConstructor;
 6import lombok.Setter;
 7import javax.persistence.*;
 8import java.util.ArrayList;
 9import java.util.Collection;
10
11@Setter @Getter @Entity @AllArgsConstructor @NoArgsConstructor
12public class User {
13    @Id @GeneratedValue(strategy = GenerationType.AUTO)
14    private Long id;
15
16    private String username;
17
18    private String name;
19
20    private String password;
21
22    @ManyToMany(fetch = FetchType.EAGER)
23    private Collection<Role> roles = new ArrayList<>();
24}
 1package cn.ustcsoc.medicinemanage.model.po.user;
 2
 3import lombok.AllArgsConstructor;
 4import lombok.Getter;
 5import lombok.NoArgsConstructor;
 6import lombok.Setter;
 7import javax.persistence.Entity;
 8import javax.persistence.GeneratedValue;
 9import javax.persistence.GenerationType;
10import javax.persistence.Id;
11
12@Entity @Getter @Setter @AllArgsConstructor @NoArgsConstructor
13public class Role {
14    @Id @GeneratedValue(strategy = GenerationType.AUTO)
15    private Long id;
16
17    private String name;
18}

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

编写Repository接口

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

Spring Security配置

 1package cn.ustcsoc.medicinemanage.config;
 2
 3import cn.ustcsoc.medicinemanage.config.filter.JWTAuthenticationFilter;
 4import cn.ustcsoc.medicinemanage.config.filter.JWTAuthorizationFilter;
 5import lombok.RequiredArgsConstructor;
 6import org.springframework.context.annotation.Bean;
 7import org.springframework.context.annotation.Configuration;
 8import org.springframework.http.HttpMethod;
 9import org.springframework.security.authentication.AuthenticationManager;
10import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
11import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
12import org.springframework.security.config.annotation.web.builders.HttpSecurity;
13import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
14import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
15import org.springframework.security.config.http.SessionCreationPolicy;
16import org.springframework.security.core.userdetails.UserDetailsService;
17import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
18import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
19
20@Configuration
21@EnableWebSecurity
22@EnableGlobalMethodSecurity(prePostEnabled = true)
23@RequiredArgsConstructor
24public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
25    private final UserDetailsService userDetailsService;
26    private final BCryptPasswordEncoder bCryptPasswordEncoder;
27
28    @Override
29    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
30        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
31    }
32
33    @Override
34    protected void configure(HttpSecurity http) throws Exception {
35        JWTAuthenticationFilter customAuthenticationFilter = new JWTAuthenticationFilter();
36        customAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
37        
38        http
39                .csrf()
40                .disable()
41                .sessionManagement()
42                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
43                .and()
44                .addFilter(customAuthenticationFilter)
45                .addFilterBefore(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
46                .authorizeRequests()
47                .antMatchers(HttpMethod.POST, "/login").permitAll()
48                .antMatchers(HttpMethod.GET, "/user/**").hasAnyAuthority("ROLE_USER")
49                .antMatchers(HttpMethod.POST, "/user/save/**").hasAnyAuthority("ROLE_ADMIN")
50                .antMatchers(HttpMethod.GET, "/users").hasAnyAuthority("ROLE_ADMIN")
51                .anyRequest().denyAll();
52    }
53}

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

关键注解:

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

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

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

JWT相关Filter

认证

 1@Slf4j
 2@RequiredArgsConstructor
 3public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
 4    private final Environment environment;
 5
 6    @Override
 7    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
 8        // create jwt for the user
 9        User user = (User) authResult.getPrincipal();
10        Algorithm algorithm = Algorithm.HMAC256(environment.getProperty("jwt.secret","default-secret").getBytes());
11        String accessToken = JWT.create()
12                .withSubject(user.getUsername())
13                .withExpiresAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
14                .withIssuer(request.getRequestURL().toString())
15                .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
16                .sign(algorithm);
17        response.setHeader("token", accessToken);
18    }
19}

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

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

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

授权

 1@Slf4j
 2@RequiredArgsConstructor
 3public class JwtAuthorizationFilter extends OncePerRequestFilter {
 4    private final Environment environment;
 5
 6    @Override
 7    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
 8        // if it is a login request, skip to login filter.
 9        if (request.getServletPath().equals("/api/login")) {
10            filterChain.doFilter(request, response);
11        } else {
12            String token = request.getHeader(HttpHeaders.AUTHORIZATION);
13            if (token != null) {
14                try {
15                    // verify jwt
16                    Algorithm algorithm = Algorithm.HMAC256(environment.getProperty("jwt.secret", "default-secret").getBytes());
17                    JWTVerifier verifier = JWT.require(algorithm).build();
18                    DecodedJWT decodedJWT = verifier.verify(token);
19                    // if no error, extract info from jwt
20                    String username = decodedJWT.getSubject();
21                    String[] roles = decodedJWT.getClaim("roles").asArray(String.class);
22                    Date expiresAt = decodedJWT.getExpiresAt();
23                    log.info("User {} JWT OK, will expire at {}", username, expiresAt.toString());
24                    Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
25                    Arrays.stream(roles).forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
26                    // create authentication token
27                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, null, authorities);
28                    // This means web application has completed authorization and actually given the right to THIS request
29                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
30                } catch (Exception e) {
31                    
32                } finally {
33                    filterChain.doFilter(request, response);
34                }
35            } else {
36                // if there is no jwt, no authorization for guest
37                filterChain.doFilter(request, response);
38            }
39        }
40    }
41}

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

授权中的逻辑是,首先检查请求是不是登录请求。如果是登录请求(默认以/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被盗用,由于其短期快速过期的特点,也不容易造成太大损失。

#Spring