本篇是关于 Spring Security 安全认证框架的基础知识,市面上还有其他比较流行的开源认证框架,例如:Shrio、Sa-Token,三者的轻量级分别为:Sa-Token 最轻,Shrio 次之,Spring Security 最重。Sa-Token 说,使用 Spring Security 需经历三拜九叩,那今天就来对 Spring Security 进行拜一拜、叩一叩,毕竟心诚则灵。

一、牛刀小试

新建 SpringBoot 项目,名称为 spring-security-demo,pom.xml 配置新如下

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.1.RELEASE</version>
  </parent>
  <modelVersion>4.0.0</modelVersion>
  <groupId>org.example</groupId>
  <artifactId>spring-security-demo</artifactId>
  <version>1.0-SNAPSHOT</version>
  <dependencies>
    <!--spring-boot-starter-web-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--lombok-->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
    </dependency>
  </dependencies>
</project>

application.yml 配置如下

server:
  port: 8060
spring:
  application:
    name: spring-security-demo

启动类

@SpringBootApplication
public class SpringSecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringSecurityDemoApplication.class,args);
    }
}

新建一个 HelloController 测试一下

@RestController
public class HelloController {

    @GetMapping("hello")
    public String hello(){
        return "Hello Spring Security";
    }

}

访问 http://localhost:8060/hello 测试一下,结果如下

Spring Security + JWT 之心诚则灵_Spring Security

此时,项目结构如下

Spring Security + JWT 之心诚则灵_JWT_02

添加 Spring Security 依赖

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

重新运行 SpringSecurityDemoApplication 启动类,能看到控制台打印以下日志。

Using generated security password: cc440ed7-23a8-4054-bb64-5c0962ee5331

2023-06-13 17:47:31.771  INFO 26056 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Creating filter chain: any request, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@58a2b917, org.springframework.security.web.context.SecurityContextPersistenceFilter@53a7a60c, org.springframework.security.web.header.HeaderWriterFilter@56399b9e, org.springframework.security.web.csrf.CsrfFilter@5349b246, org.springframework.security.web.authentication.logout.LogoutFilter@7add838c, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@5853ca50, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@79b2852b, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@48904d5a, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@24eb65e3, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@6a87026, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@7c601d50, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@12bbfc54, org.springframework.security.web.session.SessionManagementFilter@43fda8d9, org.springframework.security.web.access.ExceptionTranslationFilter@6242ae3b, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@557a84fe]
2023-06-13 17:47:31.798  INFO 26056 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8060 (http) with context path ''
2023-06-13 17:47:31.800  INFO 26056 --- [           main] o.example.SpringSecurityDemoApplication  : Started SpringSecurityDemoApplication in 0.955 seconds (JVM running for 1.381)

先来看看日志中 Using generated security password,这是 Spring Security 产生的随机密码,用户名为user。这时如果你想继续访问 http://localhost:8060/hello,则会跳转到一个 http://localhost:8060/login 的地址页面,要求你需要输入用户名和密码进行登录验证。

Spring Security + JWT 之心诚则灵_JWT_03

这时我们输入用户名为 user,密码为控制台日志打印中的 cc440ed7-23a8-4054-bb64-5c0962ee5331,成功登录。

Spring Security + JWT 之心诚则灵_Spring Security_04

再来看看控制台中的 o.s.s.web.DefaultSecurityFilterChain 日志,这是 Spring Security 默认的过滤链,有下面15个。

  • WebAsyncManagerIntegrationFilter
  • SecurityContextPersistenceFilter
  • HeaderWriterFilter
  • CsrfFilter
  • LogoutFilter
  • UsernamePasswordAuthenticationFilter
  • DefaultLoginPageGeneratingFilter
  • DefaultLogoutPageGeneratingFilter
  • BasicAuthenticationFilter
  • RequestCacheAwareFilter
  • SecurityContextHolderAwareRequestFilter
  • AnonymousAuthenticationFilter
  • SessionManagementFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor

以上过滤链,通过一个叫 WebSecurityConfigurerAdapter 的配置管理类进行管理维护,有三个名称为 configure 的重载方法,分别是:

  • configure(AuthenticationManagerBuilder auth): 配置用户认证相关的操作。
  • configure(HttpSecurity http) :配置如何通过拦截器保护请求。
  • configure(WebSecurity web) :配置Spring Security的过滤器链。

Spring Security + JWT 之心诚则灵_Spring Security_05

我们可以新建一个取名为 CustomWebSecurityConfigurer 的类来继承 WebSecurityConfigurerAdapter,通过重写 configure 的方法来改变默认的行为。

先来重写 configure(AuthenticationManagerBuilder auth) 方法,通过对象参数 auth,我们可以看到如下截图显示的方法列表,

Spring Security + JWT 之心诚则灵_Spring Boot_06

我们看到,有个叫 inMemoryAuthentication() 的方法,该方法返回值是一个构造着对象,可以设置用户相关的信息,我们用他来设置用户名和密码。

Spring Security + JWT 之心诚则灵_Spring Security_07

上面的操作中,我们的登录密码是从控制台中获取的随机密码,现在改成从内存中获取,直接写死用户名和密码。我们对 CustomWebSecurityConfigurer 类添加以下代码。

@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {

        auth.inMemoryAuthentication().withUser("test").password(passwordEncoder().encode("123456")).roles("USER");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/**").hasRole("USER").and().httpBasic();
    }
}

其中 PasswordEncoder 是 Spring Security 编码器,处理加密相关的操作,我们一般不使用他默认的加密方式,我们使用 BCryptPasswordEncoder 来处理加密操作,BCryptPasswordEncoder 采用了 SHA-256+随机盐+密钥的算法对密码进行加密。

在 configure(AuthenticationManagerBuilder auth) 方法中,我们设置了用户名、密码、用户角色,因为 Spring Security 存储到数据库中的密码是加密后的密文,因此在这个地方需要进行 passwordEncoder().encode()。

在 configure(HttpSecurity http) 方法中,我们对“USER”角色的用户请求进行放行。

重新启动服务,此时控制台已经不再打印 Using generated security password 了,需要使用我们自己写在代码中的用户名和密码了。

Spring Security + JWT 之心诚则灵_JWT_08

我们重新访问 http://localhost:8060/hello,输入用户名:test,密码:123456,成功跳转。

Spring Security + JWT 之心诚则灵_Spring Boot_09

Spring Security + JWT 之心诚则灵_JWT_10

至此,一套最简单的登录验证流程已完成,下面继续叩拜。

在 configure(AuthenticationManagerBuilder auth) 方法中操作 inMemoryAuthentication() 方法的时候,看到有个叫 jdbcAuthentication() 的家伙。没错,就是他来接入数据源的。我们点击 jdbcAuthentication() 方法进去看一下,发现代码如下,

public JdbcUserDetailsManagerConfigurer<AuthenticationManagerBuilder> jdbcAuthentication() throws Exception {
        return (JdbcUserDetailsManagerConfigurer)this.apply(new JdbcUserDetailsManagerConfigurer());
    }

再点击 JdbcUserDetailsManagerConfigurer 类进去看一下,发现代码如下,

public JdbcUserDetailsManagerConfigurer() {
        this(new JdbcUserDetailsManager());
    }

再点击 JdbcUserDetailsManager 打开这个类,发现到尽头了,JdbcUserDetailsManager 这个类的上面是一堆定义好的sql语句。

Spring Security + JWT 之心诚则灵_Spring Security_11

上面的sql语句总共涉及到五个表,分别如下:

  • users:用户表
  • groups:用户组表
  • group_members:用户组-成员表
  • authorities:权限表
  • group_authorities:用户组-权限表

根据 JdbcUserDetailsManager 类中的 insert 语句,可以整理出上面五个表的建表 sql 如下:

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for authorities
-- ----------------------------
DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(20) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  `authority` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for group_authorities
-- ----------------------------
DROP TABLE IF EXISTS `group_authorities`;
CREATE TABLE `group_authorities`  (
  `group_id` int NOT NULL AUTO_INCREMENT,
  `authority` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`group_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for group_members
-- ----------------------------
DROP TABLE IF EXISTS `group_members`;
CREATE TABLE `group_members`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(20) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  `group_id` int NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for groups
-- ----------------------------
DROP TABLE IF EXISTS `groups`;
CREATE TABLE `groups`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `group_name` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(20) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  `password` varchar(200) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  `enabled` tinyint NULL DEFAULT NULL,
  `email` varchar(50) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb3 COLLATE = utf8mb3_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

建表后的数据库模型如下

Spring Security + JWT 之心诚则灵_JWT_12

其中 groups(用户组表)可以理解为公司中的部门,group_authorities(用户组-权限表)可以理解为部门对应的权限,group_members(用户组-成员表)可以理解为部门中的员工。

执行下面 sql 语句,插入用户信息,其中密码为明文 123456 经过 BCryptPasswordEncoder 加密后的结果。

INSERT INTO `oauth2-test`.`users` (`id`, `username`, `password`, `enabled`, `email`) VALUES (2, 'test', '$2a$10$yoCL3vbEJshGI2GcOgKf6OsMJeRIJI9TRIfPOdUMB76Kd8QfHQ1QG', 1, '22222@163.com');
INSERT INTO `oauth2-test`.`authorities` (`id`, `username`, `authority`) VALUES (2, 'test', 'USER');

接下来,我们开始创建数据源,pom.xml 添加依赖

<!-- mysql-->
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <!-- mybatis-plus-boot-starter-->
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-boot-starter</artifactId>
      <version>3.4.3</version>
    </dependency>

application.yml 添加数据源配置

datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/oauth2-test?serverTimezone=GMT%2B8&useSSL=false&characterEncoding=utf-8&nullCatalogMeansCurrent=true
    username: root
    password: root

修改 CustomWebSecurityConfigurer 配置类

@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Resource
    private DataSource dataSource;
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {

        //auth.inMemoryAuthentication().withUser("test").password(passwordEncoder().encode("123456")).roles("USER");
        auth.jdbcAuthentication().dataSource(dataSource).passwordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/**").hasAnyAuthority("USER").and().httpBasic();
    }
}

从 CustomWebSecurityConfigurer 类中可以看到注入了数据源 DataSource,同时将configure(AuthenticationManagerBuilder auth) 方法中的认证方式修改为 jdbc,并且将 configure(HttpSecurity http) 方法的中的 hasRole("USER") 修改为 hasAnyAuthority("USER"),这是因为要对应上面 authorities 表中 authority 字段的值“USER”。现在我们可以重新启动服务进行验证了。我们重新访问 http://localhost:8060/hello,输入用户名:test,密码:123456,成功跳转。

Spring Security + JWT 之心诚则灵_Spring Security_13

下面我们继续实现从数据库中查询用户信息、权限信息的逻辑,我们点一下 auth,看到有一个叫 userDetailsService 的家伙,

Spring Security + JWT 之心诚则灵_Spring Boot_14

点击 userDetailsService 进去看一下,发现代码如下。

public <T extends UserDetailsService> DaoAuthenticationConfigurer<AuthenticationManagerBuilder, T> userDetailsService(T userDetailsService) throws Exception {
        this.defaultUserDetailsService = userDetailsService;
        return (DaoAuthenticationConfigurer)this.apply(new DaoAuthenticationConfigurer(userDetailsService));
    }

从上面的代码中可以知道,该方法的参数是要传入 一个UserDetailsService 的子类,我们再点击 UserDetailsService 进去看看,发现他是一个接口,而且接口只有一个方法 loadUserByUsername(String var1)。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

loadUserByUsername(String var1) 的返回值是一个 UserDetails 对象,UserDetails 内容如下。

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

其中 getAuthorities() 是权限集合,其他属性分别为用户名、密码等等。这时我们可以自定义一个类UserDetailsServiceImpl 来继承 UserDetailsService,然后通过重写覆盖 loadUserByUsername(String var1) 来构造我们想要的 UserDetails。UserDetails 也有一个自带的实现类 User,如下。

Spring Security + JWT 之心诚则灵_JWT_15

我们希望重写 loadUserByUsername(String var1) 方法的时候返回更多的字段信息,我们自定义一个叫LoginUserDetails 的类,如下。

public class LoginUserDetails implements UserDetails {


    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private Integer userid;
    
    private String username;

    private String password;

    private Collection<? extends GrantedAuthority> authorities;

    private boolean accountNonExpired;

    private boolean accountNonLocked;

    private boolean credentialsNonExpired;

    private boolean enabled;

    public LoginUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities,Integer userid) {
        this(username, password, true, true, true, true, authorities,userid);
    }

    public LoginUserDetails(String username, String password, boolean enabled, boolean accountNonExpired,
                boolean credentialsNonExpired, boolean accountNonLocked,
                Collection<? extends GrantedAuthority> authorities,Integer userid) {
        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.accountNonExpired = accountNonExpired;
        this.credentialsNonExpired = credentialsNonExpired;
        this.accountNonLocked = accountNonLocked;
        this.authorities = authorities;
        this.userid = userid;
    }


    public Integer getUserid(){
        return this.userid;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

}

LoginUserDetails 在继承 UserDetails 的基础上只增加了 userid 一个字段,后续若有需要,则可继续增加。下面我们使用 LoginUserDetails 来重写 loadUserByUsername(String var1) 方法。

创建 SysUserEntity(对应表users)、SysUserMapper、SysUserService、SysUserServiceImpl类如下。

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("users")
public class SysUserEntity implements Serializable {

    /**
     * 主键
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 启用禁用
     */
    private Integer enabled;

    /**
     * 电子邮箱
     */
    private String email;

}

@Mapper
public interface SysUserMapper extends BaseMapper<SysUserEntity> {

}

public interface SysUserService {

    /**
     *
     * @param username
     * @return
     * @author  Rommel
     * @date    2023/6/14-23:48
     * @version 1.0
     * @description  根据用户名查询用户信息
     */
    SysUserEntity selectByUsername(String username);

}

@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUserEntity> implements SysUserService {

    @Override
    public SysUserEntity selectByUsername(String username) {
        LambdaQueryWrapper<SysUserEntity> lambdaQueryWrapper = new LambdaQueryWrapper();
        lambdaQueryWrapper.eq(SysUserEntity::getUsername,username);
        return this.getOne(lambdaQueryWrapper);
    }
}

创建 SysAuthorityEntity(对应表 authorities)、SysAuthorityMapper、SysAuthorityService、SysAuthorityServiceImpl 类如下。

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("authorities")
public class SysAuthorityEntity implements Serializable {

    /**
     * 主键
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 权限
     */
    private String authority;

}

@Mapper
public interface SysAuthorityMapper extends BaseMapper<SysAuthorityEntity> {

}

public interface SysAuthorityService {

    /**
     *
     * @param username
     * @return
     * @author  Rommel
     * @date    2023/6/14-18:26
     * @version 1.0
     * @description  根据用户名查询权限集合
     */
    List<SysAuthorityEntity> listByUsername(String username);
    
}

@Service
public class SysAuthorityServiceImpl extends ServiceImpl<SysAuthorityMapper, SysAuthorityEntity> implements SysAuthorityService {
    @Override
    public List<SysAuthorityEntity> listByUsername(String username) {
        LambdaQueryWrapper<SysAuthorityEntity> lambdaQueryWrapper = new LambdaQueryWrapper();
        lambdaQueryWrapper.eq(SysAuthorityEntity::getUsername,username);
        return this.list(lambdaQueryWrapper);
    }
}

创建 UserDetailsServiceImpl 类继承 UserDetailsService 接口,代码如下,

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private SysUserService sysUserService;
    @Resource
    private SysAuthorityService authoritiesService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        SysUserEntity sysUserEntity = sysUserService.selectByUsername(username);
        if(Objects.isNull(sysUserEntity)){
            throw new RuntimeException("用户不存在!");
        }

        List<SysAuthorityEntity> authoritieList = authoritiesService.listByUsername(username);
        if(CollectionUtils.isEmpty(authoritieList)){
            throw new RuntimeException("用户无权限!");
        }

        List<GrantedAuthority> grantedAuthorityList = authoritieList.stream().map(l->{
            return new SimpleGrantedAuthority(l.getAuthority());
        }).collect(Collectors.toList());

        return new LoginUserDetails(username,sysUserEntity.getPassword(),grantedAuthorityList,sysUserEntity.getId());
    }
}

上面 loadUserByUsername(String username) 方法中,先根据用户名查询用户信息在数据库中是否存在,然后根据用户名查询是否有权限,如果有,则放进 LoginUserDetails 的构造方法中。

回到 CustomWebSecurityConfigurer 类,在 configure(AuthenticationManagerBuilder auth) 方法中将 UserDetailsServiceImpl 加入到 auth 对象中去(切记:要加在 jdbcAuthentication 前面),代码如下。

@EnableWebSecurity
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    
    @Resource
    private DataSource dataSource;
    @Resource
    private UserDetailsServiceImpl userDetailsServiceImpl;

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsServiceImpl);
        auth.jdbcAuthentication().dataSource(dataSource).passwordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/**").hasAnyAuthority("USER").and().httpBasic();
    }
}

重启服务,在 UserDetailsServiceImpl 的 loadUserByUsername(String username) 方法中打上断点debug 一下,重新访问 http://localhost:8060/hello,输入不存在的用户名 test2222 和密码 1111,发现已经进入到我们自定义的代码逻辑中去了,说明我们自定义的 UserDetailsServiceImpl 已经起到作用了。

Spring Security + JWT 之心诚则灵_Spring Security_16

接着,我们来看看下面的登录请求是怎么被拦截处理的。

Spring Security + JWT 之心诚则灵_Spring Boot_17

还是从 WebSecurityConfigurerAdapter 这个类下手,我们找到 configure(HttpSecurity http) 方法,代码如下,

protected void configure(HttpSecurity http) throws Exception {
        this.logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
        ((HttpSecurity)((HttpSecurity)((ExpressionUrlAuthorizationConfigurer.AuthorizedUrl)http.authorizeRequests().anyRequest()).authenticated().and()).formLogin().and()).httpBasic();
    }

发现有一个 formLogin() 方法,点进去看一下,代码如下,

public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
        return (FormLoginConfigurer)this.getOrApply(new FormLoginConfigurer());
    }

再点击 FormLoginConfigurer 进去看看,代码如下,

public FormLoginConfigurer() {
        super(new UsernamePasswordAuthenticationFilter(), (String)null);
        this.usernameParameter("username");
        this.passwordParameter("password");
    }

此时我们看到了 UsernamePasswordAuthenticationFilter 这个过滤器,同时也看到了用户名(username)、密码字段(password),我们启动服务时,控制台打印日志的过滤链中就有 UsernamePasswordAuthenticationFilter 这个家伙,下面我们就来认识一下他。点击进去 UsernamePasswordAuthenticationFilter 这个类,核心代码如下,

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

    public UsernamePasswordAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            String password = this.obtainPassword(request);
            if (username == null) {
                username = "";
            }

            if (password == null) {
                password = "";
            }

            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

通过上面的代码可以知道,UsernamePasswordAuthenticationFilter 就是一个以“/login”为 url 拦截,表单请求参数为 username、password,请求方式为 POST 的登录接口。我们查看登录页的 HTML 源代码如下,

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <title>Please sign in</title>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/4.0/examples/signin/signin.css" rel="stylesheet" crossorigin="anonymous"/>
  </head>
  <body>
     <div class="container">
      <form class="form-signin" method="post" action="/login">
        <h2 class="form-signin-heading">Please sign in</h2>
        <p>
          <label for="username" class="sr-only">Username</label>
          <input type="text" id="username" name="username" class="form-control" placeholder="Username" required autofocus>
        </p>
        <p>
          <label for="password" class="sr-only">Password</label>
          <input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        </p>
<input name="_csrf" type="hidden" value="d2574223-4fe8-4b60-ab32-2b8c734ad66f" />
        <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
      </form>
</div>
</body></html>

发现 method="post"、 action="/login"、 id="username" 、id="password" 通通对上号了。

二、自定义登录

上面的登录流程是系统自带的登录流程,在实际应用中,我们需要根据业务场景定义自己的登录流程。现在的应用大多都是前后端分离的架构,登录和执行业务基本都是按照以下流程。

Spring Security + JWT 之心诚则灵_JWT_18

我们再来看看 UsernamePasswordAuthenticationFilter 这个对登录请求拦截后的处理,拿到用户名、密码后构建了一个 UsernamePasswordAuthenticationToken,然后就交给 AuthenticationManager的authenticate(authRequest) 方法处理了,AuthenticationManager 是个接口,通过 debug 可以知道进入的实现类是 ProviderManager,

Spring Security + JWT 之心诚则灵_Spring Boot_19

ProviderManager 类的 authenticate(Authentication authentication) 方法遍历执行 AuthenticationProvider 接口实现类的 authenticate(Authentication authentication) 方法,当 debug执行到 provider.authenticate(authentication) 方法时,进入到 AbstractUserDetailsAuthenticationProvider 实现类的 authenticate(Authentication authentication) 方法,

Spring Security + JWT 之心诚则灵_Spring Security_20

上面截图代码中,先从缓存中查询UserDetails信息,如果缓存中查询不到,则调用 retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication) 方法进行查询,该方法是 AbstractUserDetailsAuthenticationProvider 类的抽象方法,方法实现在 DaoAuthenticationProvider 类中。

Spring Security + JWT 之心诚则灵_JWT_21

上面截图的代码正是我们熟悉的 UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username),AbstractUserDetailsAuthenticationProvider的authenticate(Authentication authentication) 方法查询到 UserDetails 后,经过一系列校验后,构建出一个认证后的 UsernamePasswordAuthenticationToken 返回。

Spring Security + JWT 之心诚则灵_Spring Boot_22

现在我们通过模仿 DaoAuthenticationProvider 类来实现我们的自定义用户名、密码登录,并且返回 jwt token。

pom.xml 添加 jwt 依赖,

<!--jwt-->
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
      <version>0.9.1</version>
    </dependency>

新建 CustomDaoAuthenticationProvider 类如下,

public class CustomDaoAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    private PasswordEncoder passwordEncoder;


    public CustomDaoAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationFilter.class);
    }


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        String password = authentication.getCredentials().toString();
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        if (!passwordEncoder.matches(password,userDetails.getPassword())) {
            throw new RuntimeException("密码错误");
        }

         return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}

这里在创建 CustomDaoAuthenticationProvider 时候,我们选择实现 AuthenticationProvider 接口,而不去继承 AbstractUserDetailsAuthenticationProvider 类,因为 AbstractUserDetailsAuthenticationProvider 的很多方法方法用不上。

新建 LoginDto、SysLoginController、SysLoginService、SysUserServiceImpl、ResponseResult、ResultCodeEnum、JwtUtils 如下。

@Data
public class LoginDto {

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;
}


@RestController
@RequestMapping("/sys")
public class SysLoginController {

    @Resource
    private SysLoginService sysLoginService;

    @PostMapping("/login")
    public ResponseResult login(@RequestBody LoginDto loginDto){

        return sysLoginService.login(loginDto);
    }
}

public interface SysLoginService {

    /**
     *
     * @param loginDto
     * @return
     * @author  Rommel
     * @date    2023/6/18-0:36
     * @version 1.0
     * @description  账号密码登录
     */
    ResponseResult login(LoginDto loginDto);

}

@Service
public class SysLoginServiceImpl implements SysLoginService {

    @Resource
    private UserDetailsServiceImpl userDetailsServiceImpl;

 @Override
    public ResponseResult login(LoginDto loginDto) {

        //传入用户名和密码构建UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(loginDto.getUsername(),loginDto.getPassword());
        //交给AuthenticationManager进行认证
        CustomDaoAuthenticationProvider customDaoAuthenticationProvider = new CustomDaoAuthenticationProvider(userDetailsServiceImpl,new BCryptPasswordEncoder());
        Authentication authenticate = null;
        try {
            authenticate = customDaoAuthenticationProvider.authenticate(usernamePasswordAuthenticationToken);
        } catch (RuntimeException e) {
            return ResponseResult.fail(e.getMessage());
        }
        //判断是否认证通过
        if (Objects.isNull(authenticate)){
            return ResponseResult.fail("登录失败");
        }
        LoginUserDetails loginUserDetails = (LoginUserDetails)authenticate.getPrincipal();
        //token有效期
        Long expiration = System.currentTimeMillis() + 1000 * 60 * 60 * 2;
        //认证通过,返回jwt-token
        String jwtToken = JwtUtils.createJwt(String.valueOf(IdWorker.getId())
                ,loginUserDetails.getUsername()
                ,new Date(expiration)
                ,SignatureAlgorithm.HS512
                ,JwtUtils.base64EncodedSecretKey
        );    
        return ResponseResult.ok(jwtToken);
    }
}


@Data
public class ResponseResult<T> {

    /**
     * 状态码
     */
    private Integer code;
    /**
     * 返回信息
     */
    private String message;
    /**
     * 数据
     */
    private T data;

    private ResponseResult() {}


    /**
     *
     * @param body
     * @param resultCodeEnum
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/6/18-20:21
     * @version 1.0
     * @description  构造返回结果
     */
    public static <T> ResponseResult<T> build(T body, ResultCodeEnum resultCodeEnum) {
        ResponseResult<T> result = new ResponseResult<>();
        //封装数据
        if(body != null) {
            result.setData(body);
        }
        //状态码
        result.setCode(resultCodeEnum.getCode());
        //返回信息
        result.setMessage(resultCodeEnum.getMessage());
        return result;
    }


    /**
     *
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/6/18-20:18
     * @version 1.0
     * @description  成功-无参
     */
    public static<T> ResponseResult<T> ok() {
        return build(null,ResultCodeEnum.SUCCESS);
    }

    /**
     *
     * @param data
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/6/18-20:19
     * @version 1.0
     * @description  成功-有参
     */
    public static<T> ResponseResult<T> ok(T data) {
        return build(data,ResultCodeEnum.SUCCESS);
    }

    /**
     *
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/6/18-20:19
     * @version 1.0
     * @description  失败-无参
     */
    public static<T> ResponseResult<T> fail() {
        return build(null,ResultCodeEnum.FAIL);
    }

    /**
     *
     * @param data
     * @return
     * @param <T>
     * @author  Rommel
     * @date    2023/6/18-20:20
     * @version 1.0
     * @description  失败-有参
     */
    public static<T> ResponseResult<T> fail(T data) {
        return build(data,ResultCodeEnum.FAIL);
    }

    public ResponseResult<T> message(String msg){
        this.setMessage(msg);
        return this;
    }

    public ResponseResult<T> code(Integer code){
        this.setCode(code);
        return this;
    }
}

@Getter
public enum ResultCodeEnum {

    SUCCESS(200,"成功"),
    FAIL(201, "失败");

    private Integer code;
    private String message;

    private ResultCodeEnum(Integer code,String message) {
        this.code = code;
        this.message = message;
    }
}

public class JwtUtils {

    public static String base64EncodedSecretKey = Base64.getEncoder().encodeToString("jwt-secret".getBytes());

    /**
     *
     * @param id
     * @param sub
     * @param exp
     * @param alg
     * @param secretKey
     * @return
     * @author  Rommel
     * @date    2023/6/18-21:15
     * @version 1.0
     * @description  创建jwt
     */
    public static String createJwt(String id,String sub,Date exp,SignatureAlgorithm alg,String secretKey){

        String jwt = Jwts.builder()
                .setId(id)
                .setSubject(sub)
                //有效期两小时
                .setExpiration(exp)
                //采用什么算法是可以自己选择的,不一定非要采用HS512
                .signWith(alg, secretKey)
                .compact();

        return jwt;
    }

    /**
     *
     * @param jwtToken
     * @param secretKey
     * @return
     * @author  Rommel
     * @date    2023/6/18-21:25
     * @version 1.0
     * @description  解析jwt
     */
    public static Claims parseJwtToken(String jwt, String secretKey){
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt).getBody();
    }
}

上面代码,在 SysLoginServiceImpl 类的 login(LoginDto loginDto) 方法中,我们传入用户名、密码构造了一个 UsernamePasswordAuthenticationToken 对象,此时 authenticated=false,接着又以UserDetailsServiceImpl、BCryptPasswordEncoder 为参数构造了一个我们自定义的 CustomDaoAuthenticationProvider 对象,然后将 usernamePasswordAuthenticationToken 作为参数调用 CustomDaoAuthenticationProvider 的 authenticate(Authentication authentication) 方法,返回一个认证后的 Authentication,此时 authenticated=true,这样就完成了用户名、密码的登录验证。最后,方法返回一个 jwt token(注意:此处暂时简单地将setSubject()设置为用户名,实际开发中,一般设置为主题信息 json 字符串,jwt 密钥正常是放在配置中心或者证书当中)。

CustomWebSecurityConfigurer 类将 configure(AuthenticationManagerBuilder auth) 配置方法去掉,修改后如下。

@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用 csrf
        http.cors().and().csrf().disable()
                .authorizeRequests()
                //允许以下请求
                .antMatchers("/sys/login").anonymous()
                //所有请求需要身份认证
                .anyRequest().authenticated();
    }
}

postman 请求如下

Spring Security + JWT 之心诚则灵_JWT_23

到此,通过用户名、密码登录获取token的操作已初步完成,下面继续叩拜。

新建 SysUserController,内容如下,

@RestController
@RequestMapping("/sysUser")
public class SysUserController {

    @GetMapping("/userDetails")
    public ResponseResult userDetails(){
        UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return ResponseResult.ok(userDetails);
    }
}

现在我们已经拿到了 token,由于 /sysUser/userDetail 接口需要认证后才能访问,我们希望将 token放到请求 header 中就能访问 /sysUser/userDetail 接口。于是我们需要在服务端对访问该接口的 token 进行验证。

我们再回想最初启动时控制台打印日志中的过滤链,其中一个叫 BasicAuthenticationFilter 的过滤器,找到 doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) 方法打个断点,然后注释掉 CustomWebSecurityConfigurer 类,debug 重启服务,postman 请求访问 /sysUser/userDetail 接口如下,

Spring Security + JWT 之心诚则灵_JWT_24

发现进入到 doFilterInternal 方法了,

Spring Security + JWT 之心诚则灵_JWT_25

该方法通过调用 this.authenticationConverter.convert(request) 构造UsernamePasswordAuthenticationToken 对象,UsernamePasswordAuthenticationToken 如果为空,则放行什么都不做,如果不为空,则返回成功认证结果并且将认证后的 Authentication 设置到 SecurityContextHolder 上下文中,this.authenticationConverter.convert(request) 方法代码如下,

@Override
	public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
		String header = request.getHeader(HttpHeaders.AUTHORIZATION);
		if (header == null) {
			return null;
		}
		header = header.trim();
		if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
			return null;
		}
		if (header.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) {
			throw new BadCredentialsException("Empty basic authentication token");
		}
		byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
		byte[] decoded = decode(base64Token);
		String token = new String(decoded, getCredentialsCharset(request));
		int delim = token.indexOf(":");
		if (delim == -1) {
			throw new BadCredentialsException("Invalid basic authentication token");
		}
		UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(token.substring(0, delim),
				token.substring(delim + 1));
		result.setDetails(this.authenticationDetailsSource.buildDetails(request));
		return result;
	}

该方法是从请求头 Authorization 获取格式为 Basic xxx 的值,其中 xxx 部分是对用户名、密码进行 base64 编码的字符串,然后对他进行解析、认证,最后构造出一个已认证的的 UsernamePasswordAuthenticationToken。

不出意外的话,上面的 postman 请求 /sysUser/userDetail 接口将返回 401。我们可以模仿 BasicAuthenticationFilter 创建一个名称叫 JwtAuthenticationTokenFilter 的过滤器来实现我们想要的逻辑,然后将他加入到过滤链中,JwtAuthenticationTokenFilter 代码如下,

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String username;
        try {
            Claims claims = JwtUtils.parseJwt(token,JwtUtils.base64EncodedSecretKey);
            Date now = new Date();
            if (now.getTime() > claims.getExpiration().getTime()) {
                throw new RuntimeException("登录已过期,请重新登陆");
            }
            username = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }

    	LoginUserDetails loginUserDetails = (LoginUserDetails)userDetailsService.loadUserByUsername(username);
        if(Objects.isNull(loginUserDetails)){
            throw new RuntimeException("账号信息不存在");
        }

        //构建UsernamePasswordAuthenticationToken对象
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(loginUserDetails,null,loginUserDetails.getAuthorities());
        //将authenticationToken对象放入SecurityContextHolder上下文中
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

在 JwtAuthenticationTokenFilter 类的 doFilterInternal 方法中,我们从 Header 中获取 token,然后对他进行 jwt 解析,验证 token 合法性,token 验证通过后后,通过从 token 中解析出来的 username查询出 UserDetails 信息,然后构建出一个已认证的 UsernamePasswordAuthenticationToken 对象,最后将 UsernamePasswordAuthenticationToken 放入到 SecurityContextHolder 上下文中并且放行。 CustomWebSecurityConfigurer 配置类如下,

@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
    
    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用 csrf
        http.cors().and().csrf().disable()
                .authorizeRequests()
                //允许以下请求
                .antMatchers("/sys/login").anonymous()
                //所有请求需要身份认证
                .anyRequest().authenticated();
        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

重启服务,postman 访问如下,

Spring Security + JWT 之心诚则灵_Spring Security_26

至此,一个粗糙版的登录获取 token、携带 token 请求接口流程基本完成。

三、登录注销

目前的功能还不能支持退出登录或踢下线的操作,下面我们来继续完善。我们需要整合 redis,我们在登录颁发 token 时,以用户 id 为 key(考虑到不同类型的客户端不互相影响,例如 app 的退出不影响 web 的登录状态,此时 key 应加前缀以区分,例如 login:user:token:app:10001),token 为 value,将他存储到 redis 中,收到退出登录请求时,根据 key 将 redis 中的 token 移除。至于踢下线操作,因登录时根据 key 对新 token 进行 redis 存储的时候就已经覆盖掉旧的 token 了。

pom.xml 添加依赖如下,

<!--redis-->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!--json-->
    <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>2.0.32</version>
    </dependency>

application.yml 添加 redis 连接配置如下,

redis:
    host: localhost
    port: 6379
    password: 123456
    database: 0

redis 系列化器如下,

public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }

    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

RedisTemplate 通过配置注入 Bean 如下,

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        //key序列号器设置为字符串
        template.setKeySerializer(new StringRedisSerializer());
        //value序列号器设置为fastJson
        template.setValueSerializer(new FastJsonRedisSerializer(Object.class));
        template.afterPropertiesSet();
        return template;
    }
}

redis 常量如下,

public class Redisconstant {

    /**
     * 后台管理系统-用户登录token对应的key前缀
     */
    public static String LOGIN_SYS_USER_TOKEN_WEB_KEY="login:sys:user:token:web:";
}

SysLoginServiceImpl 类登录方法增加 redis 操作如下,

@Service
public class SysLoginServiceImpl implements SysLoginService {

    @Resource
    private UserDetailsServiceImpl userDetailsServiceImpl;
    @Resource
    public RedisTemplate redisTemplate;

    @Override
    public ResponseResult login(LoginDto loginDto) {

        //传入用户名和密码构建UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(loginDto.getUsername(),loginDto.getPassword());
        //交给AuthenticationManager进行认证
        CustomDaoAuthenticationProvider customDaoAuthenticationProvider = new CustomDaoAuthenticationProvider(userDetailsServiceImpl,new BCryptPasswordEncoder());
        Authentication authenticate = null;
        try {
            authenticate = customDaoAuthenticationProvider.authenticate(usernamePasswordAuthenticationToken);
        } catch (RuntimeException e) {
            return ResponseResult.fail(e.getMessage());
        }
        //判断是否认证通过
        if (Objects.isNull(authenticate)){
            return ResponseResult.fail("登录失败");
        }
        LoginUserDetails loginUserDetails = (LoginUserDetails)authenticate.getPrincipal();
        //token有效期
        Long expiration = System.currentTimeMillis() + 1000 * 60 * 60 * 2;
        //认证通过,返回jwt-token
        String jwtToken = JwtUtils.createJwt(String.valueOf(IdWorker.getId())
                ,loginUserDetails.getUsername()
                ,new Date(expiration)
                ,SignatureAlgorithm.HS512
                ,JwtUtils.base64EncodedSecretKey
        );
        redisTemplate.opsForValue().set(Redisconstant.LOGIN_SYS_USER_TOKEN_WEB_KEY+loginUserDetails.getUserid(),jwtToken,expiration, TimeUnit.MILLISECONDS);
        return ResponseResult.ok(jwtToken);
    }
}

postman 请求登录接口后看到 redis 存储成功。

Spring Security + JWT 之心诚则灵_Spring Boot_27

JwtAuthenticationTokenFilter 增加 token 判断如下,

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private RedisTemplate redisTemplate;



    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String username;
        try {
            Claims claims = JwtUtils.parseJwt(token,JwtUtils.base64EncodedSecretKey);
            Date now = new Date();
            if (now.getTime() > claims.getExpiration().getTime()) {
                throw new RuntimeException("登录已过期,请重新登陆");
            }
            username = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }

        LoginUserDetails loginUserDetails = (LoginUserDetails)userDetailsService.loadUserByUsername(username);
        if(Objects.isNull(loginUserDetails)){
            throw new RuntimeException("账号信息不存在");
        }
        //继续判断token有效性
        Object tokenObject = redisTemplate.opsForValue().get(Redisconstant.LOGIN_SYS_USER_TOKEN_WEB_KEY+loginUserDetails.getUserid());
        if(Objects.isNull(tokenObject)){
            throw new RuntimeException("用户未登录");
        }
        if(!token.equals(tokenObject.toString())){
            throw new RuntimeException("无效token");
        }

        //构建UsernamePasswordAuthenticationToken对象
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(loginUserDetails,null,loginUserDetails.getAuthorities());
        //将authenticationToken对象放入SecurityContextHolder上下文中
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

至此,我们已经具备了踢下线的功能了,也完成了对 token 更严谨的验证,下面我们继续完成退出功能。

SysLoginController、SysLoginService、SysLoginServiceImpl 增加 loginOut() 接口如下,

@PostMapping("/loginOut")
    public ResponseResult loginOut(){
        return sysLoginService.loginOut();
    }

    /**
     *
     * @return
     * @author  Rommel
     * @date    2023/6/19-19:22
     * @version 1.0
     * @description  退出登录
     */
    ResponseResult loginOut();

  @Override
    public ResponseResult loginOut() {

        LoginUserDetails loginUserDetails = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        String key = Redisconstant.LOGIN_SYS_USER_TOKEN_WEB_KEY+loginUserDetails.getUserid();
        redisTemplate.delete(key);

        return ResponseResult.ok("退出成功");
    }

postman 请求结果如下,

Spring Security + JWT 之心诚则灵_JWT_28

四、自定义异常

我们在认证授权或者校验 token 的过程中出现异常时,异常信息都是打在控制台上的,返回给前端的信息直接就 401 或 403,这样十分不友好,我们希望,碰到异常时,也能够按照 ResponseResult 的格式返回给到前端,这需要我们来实现自定义异常。

还是回到最初控制台打印日志的过滤链中,我们发现有一个叫 ExceptionTranslationFilter 的过滤器,我们进入到这个过滤器,在 doFilter 方法中的两个 catch 里面都打上断点。

Spring Security + JWT 之心诚则灵_JWT_29

我们输入正确的用户名、密码,调用 /sys/login 接口获取 token,接口请求正常,此时代码执行不进入我们设置的断点。

Spring Security + JWT 之心诚则灵_JWT_30

然后故意将 redis 中的 token 删除,

Spring Security + JWT 之心诚则灵_JWT_31

接着我们将获取到的 token 去请求 /sys/loginOut 接口,

Spring Security + JWT 之心诚则灵_Spring Boot_32

发现此时进入到了我们的设置的断点了,而且控制台日志打印了我们在 JwtAuthenticationTokenFilter 这个类中抛出了的异常。

Spring Security + JWT 之心诚则灵_Spring Boot_33

放行继续,我们就就看到 postman 返回 403 状态了,而 Body 中什么信息都没返回。

Spring Security + JWT 之心诚则灵_Spring Boot_34

我们再看一下 ExceptionTranslationFilter 过滤器对异常处理的代码如下,

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			chain.doFilter(request, response);
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Try to extract a SpringSecurityException from the stacktrace
			Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
			RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
					.getFirstThrowableOfType(AuthenticationException.class, causeChain);
			if (securityException == null) {
				securityException = (AccessDeniedException) this.throwableAnalyzer
						.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
			}
			if (securityException == null) {
				rethrow(ex);
			}
			if (response.isCommitted()) {
				throw new ServletException("Unable to handle the Spring Security Exception "
						+ "because the response is already committed.", ex);
			}
			handleSpringSecurityException(request, response, chain, securityException);
		}
	}

上面的代码中,异常被传入了 handleSpringSecurityException(request, response, chain, securityException) 方法,再看看该方法的代码如下,

private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
			FilterChain chain, RuntimeException exception) throws IOException, ServletException {
		if (exception instanceof AuthenticationException) {
			handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
		}
		else if (exception instanceof AccessDeniedException) {
			handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
		}
	}

上面 handleSpringSecurityException 方法中的代码,如果异常类是 AuthenticationException 接口的实现类,则执行 handleAuthenticationException 方法,最终去执行 commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) 方法,其实就是对认证失败的响应;接着,如果异常类是 AccessDeniedException 接口的实现类,则执行 handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception) 方法,最终去执行 handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) 方法,其实就是对授权失败的响应。

我们再回到 CustomWebSecurityConfigurer 配置类,点一下“http”,出现 exceptionHandling(),再点一下,即出现 AuthenticationEntryPoint 和 AccessDeniedHandler,

Spring Security + JWT 之心诚则灵_JWT_35

也就是说,我们可以自定义实现 AuthenticationEntryPoint 和 AccessDeniedHandler 两个接口,然后将他设置到配置当中。

ResponseResult 类增加异常返回方法,如下。

public static void exceptionResponse(HttpServletResponse response,Exception e) throws IOException {
        ResponseResult responseResult = ResponseResult.fail(e.getMessage());
        String jsonResult = JSON.toJSONString(responseResult);
        response.setStatus(200);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().print(jsonResult);
    }

分别增加 AuthenticationEntryPoint、AccessDeniedHandler 接口实现类如下,

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ResponseResult.exceptionResponse(response,authException);
    }
}

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ResponseResult.exceptionResponse(response,accessDeniedException);
    }
}

自定义异常 CunstomAuthenticationException 继承 AuthenticationException,代码如下,

public class CunstomAuthenticationException extends AuthenticationException {
    public CunstomAuthenticationException(String msg) {
        super(msg);
    }
}

将之前所有直接使用 throw new RuntimeException() 抛出的自定义异常,改为使用 throw new CunstomAuthenticationException() 抛出,例如下面代码。

Spring Security + JWT 之心诚则灵_Spring Boot_36

CustomWebSecurityConfigurer 配置类如下,

@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Resource
    private AuthenticationEntryPointImpl authenticationEntryPointImpl;
    @Resource
    private AccessDeniedHandlerImpl accessDeniedHandlerImpl;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //禁用 csrf
        http.cors().and().csrf().disable()
                .authorizeRequests()
                //允许以下请求
                .antMatchers("/sys/login").anonymous()
                //所有请求需要身份认证
                .anyRequest().authenticated();
        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        http.exceptionHandling().authenticationEntryPoint(authenticationEntryPointImpl);
        http.exceptionHandling().accessDeniedHandler(accessDeniedHandlerImpl);
    }
}

测试使用不存在的用户名访问 /sys/login 接口,测试前先将 authenticate = customDaoAuthenticationProvider.authenticate(usernamePasswordAuthenticationToken) 捕获异常的代码去掉,改为如下。

Spring Security + JWT 之心诚则灵_Spring Security_37

使用 postman 发起访问,则进入到下面方法,

Spring Security + JWT 之心诚则灵_Spring Boot_38

进而进入到我们自定义的异常处理器,

Spring Security + JWT 之心诚则灵_Spring Boot_39

postman响应结果如下。

Spring Security + JWT 之心诚则灵_Spring Security_40

由于在 JwtAuthenticationTokenFilter 抛出的异常提示信息会被覆盖,情况如下。

Spring Security + JWT 之心诚则灵_Spring Security_41

因此需对 JwtAuthenticationTokenFilter 进行改造,若 JwtAuthenticationTokenFilter 出现异常,则内部捕获,然后直接响应给前端,代码如下。

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            doFilter(request,response,filterChain);
        } catch (Exception e) {
            ResponseResult.exceptionResponse(response,e);
        }
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException{
        //获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String username;
        try {
            Claims claims = JwtUtils.parseJwt(token,JwtUtils.base64EncodedSecretKey);
            Date now = new Date();
            if (now.getTime() > claims.getExpiration().getTime()) {
                throw new CunstomAuthenticationException("登录已过期,请重新登陆");
            }
            username = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new CunstomAuthenticationException("token非法");
        }

        LoginUserDetails loginUserDetails = (LoginUserDetails)userDetailsService.loadUserByUsername(username);
        if(Objects.isNull(loginUserDetails)){
            throw new CunstomAuthenticationException("账号信息不存在");
        }
        //继续判断token有效性
        Object tokenObject = redisTemplate.opsForValue().get(Redisconstant.LOGIN_SYS_USER_TOKEN_WEB_KEY+loginUserDetails.getUserid());
        if(Objects.isNull(tokenObject)){
            throw new CunstomAuthenticationException("用户未登录");
        }
        if(!token.equals(tokenObject.toString())){
            throw new CunstomAuthenticationException("无效token");
        }

        //构建UsernamePasswordAuthenticationToken对象
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(loginUserDetails,null,loginUserDetails.getAuthorities());
        //将authenticationToken对象放入SecurityContextHolder上下文中
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

使用 postman 测试使用 redis 已删除的 token 访问 /sys/loginOut 接口,结果如下。

Spring Security + JWT 之心诚则灵_Spring Security_42

至此,我们完成了自定义异常处理。

五、权限管理

我们在访问 /sysUser/userDetails 接口的时候,只是对 token 认证通过就允许访问了。

Spring Security + JWT 之心诚则灵_Spring Boot_43

当前 token 的用户名是 test,权限是 USER,我们希望 /sysUser/userDetails 接口需要有 ADMIN 权限才能访问,我们可以通过在接口上添加权限注解来控制访问该接口需要什么权限。首先,在CustomWebSecurityConfigurer 配置类中添加 @EnableGlobalMethodSecurity(prePostEnabled =true) 注解以开启授权注解功能,如下,

Spring Security + JWT 之心诚则灵_JWT_44

然后在 /sysUser/userDetails 接口上添加 @PreAuthorize("hasAuthority('ADMIN')") 如下,

Spring Security + JWT 之心诚则灵_Spring Boot_45

使用 postman对/sysUser/userDetails 接口发起请求,结果如下,

Spring Security + JWT 之心诚则灵_JWT_46

将数据库 authorities 表中,test 用户对应的权限修改为“ADMIN”

Spring Security + JWT 之心诚则灵_Spring Boot_47

使用 postman 重新对 /sysUser/userDetails接口发起请求,结果如下,

Spring Security + JWT 之心诚则灵_Spring Security_48

至此达到我们想要的对权限管理的效果,本篇对 Spring Security 的讲解也到此结束。

本篇demo代码目录如下。

Spring Security + JWT 之心诚则灵_Spring Security_49

项目代码地址:地址链接