在这一节中我们将学习如何创建一个 springboot 应用,并且支持 基于 JWT 的token认证,将会学习到:

  1. 基于 JWT 认证的用户登录和注册流程
  2. spring boot + spring security 的架构
  3. 如何配置 spring security 支持 JWT
  4. 如何定义数据模型,并且协助认证和授权
  5. 使用 spring data jpa 的方式跟 mysql 数据库进行交互

    概览

    我们将会创建一个 Spring boot 的应用:

  6. 用户可以注册新的账户,使用用户名密码进行登录

  7. 基于用户的角色,授权给用户可以访问的资源

将提供以下 API:

Methods Urls Actions
POST /api/auth/signup 注册
POST /api/auth/signin 登录
GET /api/test/all 公开的API
GET /api/test/user 登录用户可访问的API
GET /api/test/mod 基于角色可访问的API
GET /api/test/admin 管理员可访问的API

数据库我们将选择 MYSQL

认证流程

下面的图示展示了用户如何进行注册和登录
spring-boot-authentication-jwt-spring-security-flow.png
如果客户端请求访问受保护的资源时,需要在 Header 头上带有一个合法的 JWT

SpringBoot & Spring security 架构

下面的架构有三层,分别是:

  1. http
  2. spring-security
  3. API

spring-boot-jwt-mysql-spring-security-architecture (1).png
结合上图,我们通过spring security 将这些模块轻松的组合在了一起。
首先 HTTP Request 进来, 会被拦截转发到 JwtAuthTokenFilter , 然后检查所带的请求有没有 token,如果没有则返回认证失败 -> Authentication EntryPoint

如果用户的请求是关于登录和注册, 直接放行,将用户传进来的 username 和 password 传递给 AuthenticationManager进行认证 。 为此 AuthenticationManager 要实现 configure(AuthenticationManagerBuilder auth) 方法和 实例化 authenticationManagerBean()
并将认证信息生成 JWT token 进行返回

如果用户的请求不是关于登录和请求,那么将会被拦截,进入 AuthTokenFilter 类。 解析和验证token,最后将认证信息存入 SecurityContext 中

实现过程

创建数据库

  1. create database `demo` default character set utf8 collate utf8_general_ci;

创建数据库 ORM 对象

我们一共有三个对象要创建,分别是:

  1. User : 用户对象
  2. Role: 角色对象
  3. ERole: 角色枚举对象

创建 User 对象

  1. package com.example.springbootsecurityjpa.models;
  2. import lombok.Getter;
  3. import lombok.Setter;
  4. import org.springframework.stereotype.Component;
  5. import javax.persistence.*;
  6. import javax.validation.constraints.Email;
  7. import javax.validation.constraints.NotBlank;
  8. import javax.validation.constraints.Size;
  9. import java.util.HashSet;
  10. import java.util.Set;
  11. /**
  12. * @DATA: 2020/12/3
  13. */
  14. @Setter
  15. @Getter
  16. @Entity
  17. @Table( name = "users",
  18. uniqueConstraints = {
  19. @UniqueConstraint(columnNames = "username"),
  20. @UniqueConstraint(columnNames = "email")
  21. })
  22. public class User {
  23. @Id
  24. @GeneratedValue(strategy = GenerationType.IDENTITY)
  25. private Long id;
  26. @NotBlank
  27. @Size(max = 20)
  28. private String username;
  29. @NotBlank
  30. @Size(max = 50)
  31. @Email
  32. private String email;
  33. @NotBlank
  34. @Size(max = 120)
  35. private String password;
  36. @ManyToMany(fetch = FetchType.LAZY)
  37. @JoinTable( name = "user_roles",
  38. joinColumns = @JoinColumn(name = "user_id"),
  39. inverseJoinColumns = @JoinColumn(name = "role_id"))
  40. private Set<Role> roles = new HashSet<>();
  41. public User() {
  42. }
  43. public User(String username, String email, String password) {
  44. this.username = username;
  45. this.email = email;
  46. this.password = password;
  47. }
  48. }

创建 Role 对象

  1. @Setter
  2. @Getter
  3. @Entity
  4. @Table(name = "roles")
  5. public class Role {
  6. public Role(){}
  7. public Role(Long id, @NotBlank @Size(max = 20) ERole name) {
  8. this.id = id;
  9. this.name = name;
  10. }
  11. @Id
  12. @GeneratedValue(strategy = GenerationType.IDENTITY)
  13. private Long id;
  14. @NotBlank
  15. @Size(max = 20)
  16. @Enumerated(EnumType.STRING)
  17. private ERole name;
  18. }

这里一个比较大的区别就是,我们在 Role 里面的字段 name 是一个枚举对象。但是写入数据库的时候我们表示成字符串的形式。这里似乎用 @Enumerated 进行标注。

创建 ERole 枚举对象

  1. public enum ERole {
  2. ROLE_USER,
  3. ROLE_MODERATOR,
  4. ROLE_ADMIN;
  5. }

创建数据库操作对象 Repository

Jpa 支持命名查询,可以实现简单的增删改查,我们新建一个 UserRepository

  1. public interface UserRepository extends JpaRepository<User,Long> {
  2. Optional<User> findByUsername(String username);
  3. Boolean existsByUsername(String username);
  4. Boolean existsByEmail(String email);
  5. }

RoleRepository 也是一样,这里就不赘述了。

创建 UserDetails 和 UserDetailsService

UserDetails

UserDetails 是 spring-security 中重要的一个概念,它包含了用户用来认证的主要信息。 一般我们创建了 User 类以后,要专门创建这么一个类来执行认证相关的操作。

  1. package com.example.springbootsecurityjpa.services.impl;
  2. import com.example.springbootsecurityjpa.models.User;
  3. import com.fasterxml.jackson.annotation.JsonIgnore;
  4. import com.fasterxml.jackson.annotation.JsonProperty;
  5. import org.springframework.security.core.GrantedAuthority;
  6. import org.springframework.security.core.authority.SimpleGrantedAuthority;
  7. import org.springframework.security.core.userdetails.UserDetails;
  8. import java.util.Collection;
  9. import java.util.List;
  10. import java.util.stream.Collectors;
  11. /**
  12. * @DATA: 2020/12/4
  13. */
  14. public class UserDetailsImpl implements UserDetails {
  15. private static final long serialVersionUID = 1L;
  16. private Long id;
  17. private String username;
  18. private String email;
  19. @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
  20. private String password;
  21. private Collection<? extends GrantedAuthority> authorities;
  22. public UserDetailsImpl(Long id, String username, String email, String password, Collection<? extends GrantedAuthority> authorities) {
  23. this.id = id;
  24. this.username = username;
  25. this.email = email;
  26. this.password = password;
  27. this.authorities = authorities;
  28. }
  29. public static UserDetailsImpl build(User user){
  30. List<GrantedAuthority> authorities = user.getRoles().stream()
  31. .map( role -> new SimpleGrantedAuthority(role.getName()))
  32. .collect(Collectors.toList());
  33. return new UserDetailsImpl(
  34. user.getId(),
  35. user.getUsername(),
  36. user.getEmail(),
  37. user.getPassword(),
  38. authorities
  39. );
  40. }
  41. @Override
  42. public Collection<? extends GrantedAuthority> getAuthorities() {
  43. return authorities;
  44. }
  45. @Override
  46. public String getPassword() {
  47. return password;
  48. }
  49. @Override
  50. public String getUsername() {
  51. return username;
  52. }
  53. @Override
  54. public boolean isAccountNonExpired() {
  55. return true;
  56. }
  57. @Override
  58. public boolean isAccountNonLocked() {
  59. return true;
  60. }
  61. @Override
  62. public boolean isCredentialsNonExpired() {
  63. return true;
  64. }
  65. @Override
  66. public boolean isEnabled() {
  67. return true;
  68. }
  69. public boolean equals(Object o) {
  70. if (this == o)
  71. return true;
  72. if (o == null || getClass() != o.getClass())
  73. return false;
  74. UserDetailsImpl user = (UserDetailsImpl) o;
  75. return Objects.equals(id, user.id);
  76. }
  77. }

UserDetailsService

这个类只有一个方法 loadUserByName

  1. @Service
  2. public class UserDetailsServiceIml implements UserDetailsService {
  3. @Autowired
  4. UserRepository userRepository;
  5. @Override
  6. @Transactional // 数据库事务安全。 如果出现异常不会执行任何数据库操作
  7. public UserDetails loadUserByUsername(String username) {
  8. User user = userRepository.findByUsername(username).orElseThrow(
  9. () -> new UsernameNotFoundException("user not found")
  10. );
  11. return UserDetailsImpl.build(user);
  12. }
  13. }

这里需要注意的是,这个类被标注了 @Service 注解,说明这是一个组件,会被实例化。
其中方法中标注了 @Transactional 表示这是一个数据库事务注解,如果发生异常,就不会执行任何的数据库操作

创建 JWT 工具类

我们需要封装一个工具类来 生成/解析/验证 JWT token。 这部分又会涉及到两个内容:

  1. 读取配置
  2. jjwt 的用法

读取配置

我们首先在配置文件里面添加关于 JWT 的配置信息

  1. jwt:
  2. secret: SecretKey
  3. expirationMs: 86400000

配置好了,我们需要定义一个类,来加载配置信息

  1. @Configuration
  2. @Setter
  3. @Getter
  4. @ConfigurationProperties(prefix = "jwt")
  5. public class JwtValues {
  6. private String secret;
  7. private long expirationMs;
  8. }

然后我们写一个测试,看看能不能获取到配置内容

  1. @RunWith(SpringRunner.class)
  2. @SpringBootTest
  3. public class JwtValuesTest {
  4. @Autowired
  5. JwtValues jwtValues;
  6. @Test
  7. public void loadConfigurationTest(){
  8. assertEquals("SecretKey", jwtValues.getSecret());
  9. assertTrue(86400000 == jwtValues.getExpirationMs());
  10. }
  11. }

定义 JWT 工具类

JWT 工具类一共有三个功能:

  1. 从用户名,生效日期,失效日期,secret key 四个关键词中生成 JWT
  2. 从 JWT token 中获取用户名
  3. 验证 JWT token ```java

@Component // 需要定义成组件 @Slf4j // 引入 log public class JwtUtil { @Autowired JwtValues jwtValues;

  1. SecretKey key = Keys.hmacShaKeyFor(
  2. jwtValues.getSecret().getBytes(StandardCharsets.UTF_8));
  3. // 生成token
  4. public String generate(Authentication authentication){
  5. UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
  6. return Jwts.builder()
  7. .setSubject(userDetails.getUsername())
  8. .setIssuedAt(new Date())
  9. .setExpiration(new Date( (new Date()).getTime() + jwtValues.getExpirationMs()))
  10. .signWith(key)
  11. .compact();
  12. }
  13. // 从 token 中得到用户名
  14. public String getUserFromToken(String token){
  15. String name = Jwts.parserBuilder()
  16. .setSigningKey(key)
  17. .build()
  18. .parseClaimsJws(token)
  19. .getBody()
  20. .getSubject();
  21. return name;
  22. }
  23. // 验证 token
  24. public Boolean validate(String token){
  25. try{
  26. Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
  27. return true;
  28. }catch (SignatureException e){
  29. log.error("Invalid JWT signature: {}", e.getMessage());
  30. }catch (MalformedJwtException e){
  31. log.error("Invalid JWT token: {}", e.getMessage());
  32. }catch (ExpiredJwtException e){
  33. log.error("JWT token expired: {}" , e.getMessage());
  34. }catch (UnsupportedJwtException e){
  35. log.error("Jwt Token is not supported: {}", e.getMessage());
  36. }catch (IllegalArgumentException e){
  37. log.error("Jwt claims string is empty: {}", e.getMessage());
  38. }catch (Exception e){
  39. log.error("Unexpected error happen: {}",e.getMessage());
  40. }
  41. return false;
  42. }

}

<a name="h7W4B"></a>
## 配置 spring security
<a name="PZ8Bl"></a>
### 配置认证失败
在解析 JWT token 进行认证的时候,总会出现比如 token 过期,token 失效等异常。那么这个时候就需要处理抛出的异常,为此,我们需要配置一个认证失败要如何处理的类
```java
@Component
@Slf4j
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        log.error("Unauthorized error:{}" , e.getMessage());
        httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
    }
}

配置 Filter

当用户请求进来时,我们想要将这个请求转发到 spring security 进行检查。其中的逻辑有:

  1. 从 Auhtorization header 中获取 Token
  2. 如果有 token, 解析获取 username
  3. 已知 username 那么我们就可以得到 Userdetails,然后创建对象 Authentication Object
  4. 然后将对象放进应用上下文,完成认证

然后每一次,你需要获取对象的时候,你可以使用

UserDetails userDetails =
    (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

接下来我们完成这个逻辑

@Slf4j
public class AuthTokenFilter extends OncePerRequestFilter {
    @Autowired
    JwtUtil jwtUtil;
    @Autowired
    UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        try{
            String token  = parse(httpServletRequest);
            if(token != null && jwtUtil.validate(token)) {
                String username = jwtUtil.getUserFromToken(token);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails.getUsername(),
                        null,
                        userDetails.getAuthorities()
                );
                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }catch (Exception e){
            log.error("cannot set authentication :{}",e.getMessage());
        }
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }

    private static String parse(HttpServletRequest httpServletRequest){
        String headers = httpServletRequest.getHeader("Authorization");
        if(StringUtils.hasText(headers) && headers.startsWith("Bearer")){
            String token = headers.substring(7, headers.length());
            return token;
        }

        return null;

    }
}

配置流程

最后我们需要配置一下 spring security 的流程,把这一切组件串起来。 因为我们的请求进来是通过 spring security来处理,所以我们要在配置里面声明,要把这个请求交给哪个类进行处理,处理完下一步是什么。

@SpringBootConfiguration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    AuthEntryPointJwt authEntryPointJwt;
    @Autowired
    UserDetailsService userDetailsService;
    @Bean
    public PasswordEncoder createPassworEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
    // Filter 只能通过第三方 Bean 注入,而不能通过 @Component 等注解进行注入
    @Bean
    public AuthTokenFilter creatAuthTokenFilter(){
        return new AuthTokenFilter();
    }

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

    // 之后我们要使用这个组件来进行认证
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
                .exceptionHandling().authenticationEntryPoint(authEntryPointJwt)
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests().mvcMatchers("/api/auth/**")
                .permitAll()
                .anyRequest().permitAll();
        http.addFilterBefore(creatAuthTokenFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

我们稍微解释一下上面的代码是什么意思:

  1. @EnableSecurity: 允许 Spring 寻找和应用这个类到全局的 Web 安全上
  2. @EnableGlobalMethodSecurity : 提供了 AOP形式的安全检查到方法上,这里主要是为了 @PreAuthorize 和 @PostAuthorize 两个方法。 它同时也支持 JSR-250. 在 这里 可以发现更多的安全方法
  3. 我们实例化了 PasswordEncode 如果没有实例化,将会使用明文密码
  4. 我们实例化了 AuthTokenFilter ,注入到配置里,用来拦截请求
  5. 定义了 configure(AuthenticationManagerBuilder auth) 用来说明是如何实现认证的
  6. 实例化了 AuthenticationManager , 之后就可以通过 AuthenticationManager.authenticate(username,password) 来调用上面的configure(AuthenticationManagerBuilder auth)方法进行登录验证
  7. 定义了 configure(HttpSecurity http) 这个是最关键的,它定义了如何去配置 CORS和 CSRF,并且请求进来以后,要怎么工作,以及发生异常后的处理

配置 controller

Controller 分为两部分,一部分是 Auth ,一部分是Test

auth

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    @Autowired
    UserService userService;

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@Valid @RequestBody RegisterRequest registerRequest){
        return userService.register(registerRequest);
    }

    @PostMapping("/signin")
    public ResponseEntity<?> signin(@Valid @RequestBody SigninRequest signinRequest){
        return userService.login(signinRequest);
    }
}

业务逻辑写在了 service 里

@Service
@Slf4j
public class UserServiceImpl implements UserService {
    @Autowired
    UserRepository userRepository;

    @Autowired
    RoleRepository roleRepository;
    @Autowired
    PasswordEncoder passwordEncoder;

    @Autowired
    AuthenticationManager authenticationManager;

    @Autowired
    JwtUtil jwtUtil;

    @Override
    @Transactional
    public ResponseEntity<?> register(RegisterRequest registerRequest) {
        String username = registerRequest.getName();
        String email = registerRequest.getEmail();
        String password = passwordEncoder.encode(registerRequest.getPassword());
        if(userRepository.existsByUsername(username)){
            return ResponseEntity.badRequest().body(
                    new GeneralResponse<>("username exists"));
        }
        if(userRepository.existsByEmail(email)){
            return ResponseEntity.badRequest().body(
                    new GeneralResponse<>("email exists"));
        }
        User user = new User(username,email,password);
        Set<String> strRoles = registerRequest.getRole();
        Set<Role> roles = new HashSet<>();
        if(strRoles == null){
            Role role = roleRepository.findByName(ERole.ROLE_USER).orElseThrow(()->new RuntimeException("Error: role is not exists"));
            roles.add(role);
        }else{
            strRoles.forEach(
                    roleName -> {
                        log.info(roleName);
                        switch (roleName) {
                            case "admin":
                                Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN)
                                                .orElseThrow(() -> new RuntimeException("admin role is not exists"));
                                roles.add(adminRole);
                                break;
                            case "mod":
                                Role modRole = roleRepository.findByName(ERole.ROLE_MODERATOR)
                                                    .orElseThrow(()->new RuntimeException("mod role is not exists"));
                                roles.add(modRole);
                                break;
                            default:
                                Role userRole = roleRepository.findByName(ERole.ROLE_USER)
                                        .orElseThrow(()-> new RuntimeException("user role is not exists"));
                                roles.add(userRole);

                        }
                    }
            );

        }
        user.setRoles(roles);
        userRepository.save(user);
        return ResponseEntity.ok(
                new GeneralResponse<>("Register successful!")
        );
    }

    @Override
    public ResponseEntity<?> login(SigninRequest signinRequest) {
        String username = signinRequest.getUsername();
        String password = signinRequest.getPassword();
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username,password));
        SecurityContextHolder.getContext().setAuthentication(authentication);
        String jwt = jwtUtil.generate(authentication);

        UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
        List<String> roles = userDetails.getAuthorities().stream()
                .map(item -> ((GrantedAuthority) item).getAuthority())
                .collect(Collectors.toList());
        return ResponseEntity.ok(new JwtResponse(jwt,
                userDetails.getId(),
                userDetails.getUsername(),
                userDetails.getEmail(),
                roles
        ));

    }
}

总结

我们通过 spring boot + spring-security + jwt + jpa 的技术栈实现了一个使用 JWT 认证的应用。 有以下功能

  1. 注册
  2. 登录
  3. 授权访问

这个项目还存在很多的缺点,比如 token 的刷新问题,token 的缓存(性能问题,每次解析 token 获取用户都需要去数据库查询,会出现性能问题),权限没有更加细致化等等。

代码地址:

https://github.com/codemak2r/SpringBootExample/tree/master/spring-boot-security-jpa