SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)

基于token认证功能开发

引子:最近做项目时遇到了一个特殊的需求,需要写共享接口把本系统的一些业务数据共享给各地市的自建系统,为了体现公司的专业性以及考虑到程序的扩展性(通过各地市的行政区划代码做限制),决定要把接口做的高级一些,而不是简单的传个用户名和密码对比数据库里面的,那样真的很low。于是写了基于token的认证功能,在这里分享出来供大家学习与探讨。

效果演示:

1、请求头未设置token值或者是非法token

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第1张图片

2、token失效

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第2张图片

3、登录获取token

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第3张图片

4、携带token访问API

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第4张图片

1、项目初始化

项目的初始化很重要,我们需要事先准备好一些通用的工具类和配置类,便于后面开发。

因为新建工程比较简单,这里就不啰嗦了,看下我添加了那些GAV坐标即可。

注意我用的SpringBoot版本是3.0的,如果版本和我保持一致的话pom.xml也需要保持一致否则依赖可能下载不下来(SpringBoot3.0当时还没有稳定版本的)。

1、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/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0modelVersion>

    
    
    <parent>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-parentartifactId>
        <version>3.0.7version>
        <relativePath/> 
    parent>

    <groupId>com.laizhenghuagroupId>
    <artifactId>demoartifactId>
    <version>1.0-SNAPSHOTversion>

    <properties>
        <maven.compiler.source>8maven.compiler.source>
        <maven.compiler.target>8maven.compiler.target>
    properties>

    <dependencies>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        
        
        <dependency>
            <groupId>com.baomidougroupId>
            <artifactId>mybatis-plus-boot-starterartifactId>
            <version>3.5.3version>
        dependency>
        
        <dependency>
            <groupId>com.mysqlgroupId>
            <artifactId>mysql-connector-jartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-data-redisartifactId>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-securityartifactId>
        dependency>
        
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>0.9.1version>
        dependency>
        
        
        <dependency>
            <groupId>javax.xml.bindgroupId>
            <artifactId>jaxb-apiartifactId>
            <version>2.3.1version>
        dependency>

        
        
        <dependency>
            <groupId>io.springfoxgroupId>
            <artifactId>springfox-boot-starterartifactId>
            <version>3.0.0version>
        dependency>
        
        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>fastjsonartifactId>
            <version>1.2.83version>
        dependency>

    dependencies>

    <repositories>
        <repository>
            <id>spring-snapshotsid>
            <url>https://repo.spring.io/snapshoturl>
            
            <releases>
                <enabled>trueenabled>
            releases>
            <snapshots>
                <enabled>falseenabled>
            snapshots>
        repository>
    repositories>

    <pluginRepositories>
        <pluginRepository>
            <id>spring-snapshotsid>
            <url>https://repo.spring.io/snapshoturl>
            <releases>
                <enabled>trueenabled>
            releases>
            <snapshots>
                <enabled>falseenabled>
            snapshots>
        pluginRepository>
    pluginRepositories>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.bootgroupId>
                <artifactId>spring-boot-maven-pluginartifactId>
            plugin>
        plugins>
    build>

project>

整体项目结构如下:

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第5张图片

2、统一返回类R封装

为了避免API返回的数据混乱,我们统一使用R类进行返回,R中返回的数据结构如下

{
  "msg": "success", // 附加消息
  "code": 200, // 状态码(可以自定义不一定完全与http状态码一样)
  "data": "alex", // 数据统一放在data方便前端拦截器直接拦截data
  "success": true // 成功标识(是否成功可以通过这个属性判断)
}

新建utils.R.java(所有工具类都放在utils包下)

R.java

/**
 * TODO
 *
 * @Description 统一返回类封装
 * @Author laizhenghua
 * @Date 2023/2/19 20:04
 **/
public class R extends HashMap<String, Object> {

    private static final long serialVersionUID = 563554414843661955L;

    public R() {
        put("code", 0);
        put("msg", "success");
    }

    public static R error(int code, String msg) {
        R r = new R();
        r.put("code", code);
        r.put("msg", msg);
        r.put("success", false);
        return r;
    }

    public static R success(Object data, String msg) {
        R r = new R();
        r.put("code", 200);
        r.put("data", data);
        r.put("msg", msg);
        r.put("success", true);
        return r;
    }

    public static R success(Object data) {
        return success(data, "success");
    }

    public static R ok(String msg) {
        R r = new R();
        r.put("msg", msg);
        return r;
    }

    public static R ok(Map<String, Object> map) {
        R r = new R();
        r.putAll(map);
        return r;
    }

    public static R ok() {
        return new R();
    }

    public R put(String key, Object value) {
        super.put(key, value);
        return this;
    }
}

2、RedisTemplate序列化配置

RedisTemplate默认采用JDK的序列化方式,一是不支持跨语言,最重要的是出了问题,排查起来非常不方便!因此为了保证序列化不出问题,我们需要重新配置RedisTemplate

新建config.RedisConfiguration.java(所有配置类都放在config包下)

RedisConfiguration.java

/**
 * TODO
 *
 * @Description RedisTemplate 序列化配置
 * @Author laizhenghua
 * @Date 2023/6/25 21:22
 **/
@Configuration
public class RedisConfiguration {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 我们为了开发方便直接使用泛型
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        // 序列化配置
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // String序列化的配置
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化
        template.setKeySerializer(stringRedisSerializer);
        // Hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value采用Jackson2JsonRedisSerializer的序列化方式
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // Hash的value也采用jackson2JsonRedisSerializer的序列化方式
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

测试一下redis缓存有没有问题

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/6/3 09:04
 **/
@RestController
public class HelloController {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @GetMapping("/hello")
    public R hello() {
        redisTemplate.opsForValue().set("name", "alex");
        return R.success(redisTemplate.opsForValue().get("name"));
    }
}

浏览器访问这个API,惊奇的发现自动跳转到了登录页面需要认证后才能访问API

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第6张图片

认证方式也很简单可以输入用户名和密码进行认证,如

Username: user
Password: 启动项目控制台输出的uuid
// 如 Using generated security password: f4895be9-132b-4627-a7e6-25b9b5baeb1b

// 用户名为什么user?源码如下
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
	...
	public static class User {
	/**
	 * Default user name.
	 */
	// 当然也可以通过配置文件去指定
	private String name = "user";

	/**
	 * Password for the default user name.
	 */
	private String password = UUID.randomUUID().toString();
	...
	}
}

3、SpringSecurity初步配置

以上除了在登录页面输入用户名和密码进行认证外,还有一种方式就是在请求头或其他地方增加token,通过解析token找到认证用户并给予认证(本文就是介绍这种方式)。

当然也可以配置这个请求不需要认证也不需要鉴权,这也是测试例子想引出的知识点,因为后面静态资源和一些特殊的请求是不需要认证的比如说swagger相关的。

新建SecurityConfiguration.java配置类

SecurityConfiguration.java

/**
 * TODO
 *
 * @Description SecurityConfiguration
 * @Author laizhenghua
 * @Date 2023/6/25 21:55
 **/
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    // 1.需要注意的是SpringSecurity6.0版本不再是是继承WebSecurityConfigurerAdapter来配置HttpSecurity,而是使用SecurityFilterChain来注入
    // 2.SpringSecurity6.0需要添加@EnableWebSecurity来开启一些必要的组件
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 关闭csrf因为不使用session
        http.csrf().disable()
                // 不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests()
                // 配置不需要认证的请求
                .requestMatchers("/hello").permitAll()
                // 除了上面那些请求都需要认证
                .anyRequest().authenticated();
        return http.build();
    }
    /**
     * anyRequest          |   匹配所有请求路径
     * access              |   SpringEl表达式结果为true时可以访问
     * anonymous           |   匿名可以访问
     * denyAll             |   用户不能访问
     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
     * permitAll           |   用户可以任意访问
     * rememberMe          |   允许通过remember-me登录的用户访问
     * authenticated       |   用户登录后可访问
     */
}

4、SpringBoot3.0集成Swagger3.0

目前SpringBoot3.0还不支持Swagger3.0这部分内容先不要看了,后续再更新~

集成Swagger3.0主要就是测试API比较方便,不想集成可以跳过这一步。

1、引入坐标依赖

 
 <dependency>
     <groupId>io.springfoxgroupId>
     <artifactId>springfox-boot-starterartifactId>
     <version>3.0.0version>
 dependency>

2、主程序添加@EnableOpenApi注解,这是swagger 3.0新增的注解。

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/6/3 08:35
 **/
@EnableOpenApi // 让SpringBoot扫描swagger配置的相关组件
@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

3、修改SpringMVC默认路径匹配策略(因为Springfox使用的路径匹配是基于AntPathMatcher的,而Spring Boot3.0默认使用的是PathPatternMatcher

源码如下

public static class Pathmatch {
	/**
	 * Choice of strategy for matching request paths against registered mappings.
	 */
	private MatchingStrategy matchingStrategy = MatchingStrategy.PATH_PATTERN_PARSER;
}

application.yaml

server:
  port: 8081
spring:
  datasource:
    url: jdbc:mysql://192.168.200.9:3608/pr?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
    username: pr
    password: pr@Dist123
    driver-class-name: com.mysql.cj.jdbc.Driver
  data:
    redis:
      host: 192.168.6.74
      port: 6856
      password: gh668##MMN
  mvc: # swagger3
    pathmatch:
      matching-strategy: ant_path_matcher # 主要修改这里

4、编写配置类,配置swagger

SwaggerConfiguration.java

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/6/26 21:51
 **/
@Configuration
public class SwaggerConfiguration {
    @Bean
    public Docket docket() {
        return new Docket(DocumentationType.OAS_30)
                .select().apis(RequestHandlerSelectors.basePackage("com.laizhenghua.demo.controller"))
                .paths(PathSelectors.any()).build()
                .apiInfo(setApiInfo())
                .globalRequestParameters(setRequestParameter());
    }
    // swagger默认是不可以直接添加请求头的需要单独配置
    private List<RequestParameter> setRequestParameter() {
        RequestParameter parameter = new RequestParameterBuilder()
                .name("token")
                .description("token")
                .in(ParameterType.HEADER)
                .required(true)
                .build();
        return Collections.singletonList(parameter);
    }

    private ApiInfo setApiInfo() {
        Contact contact = new Contact("laizhenghua", "https://blog.csdn.net/m0_46357847", "[email protected]");
        return new ApiInfo("SpringSecurity基于token的认证功能", "通过token认证API完整实现", "v1.0",
                "https://blog.csdn.net/m0_46357847", contact, "Apache 2.0", "", new ArrayList<VendorExtension>());
    }

    @Bean
    public static BeanPostProcessor springfoxHandlerProviderBeanPostProcessor() {
        return new BeanPostProcessor() {

            @Override
            public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
                if (bean instanceof WebMvcRequestHandlerProvider || bean instanceof WebFluxRequestHandlerProvider) {
                    customizeSpringfoxHandlerMappings(getHandlerMappings(bean));
                }
                return bean;
            }

            private <T extends RequestMappingInfoHandlerMapping> void customizeSpringfoxHandlerMappings(List<T> mappings) {
                List<T> copy = mappings.stream()
                        .filter(mapping -> mapping.getPatternParser() == null)
                        .collect(Collectors.toList());
                mappings.clear();
                mappings.addAll(copy);
            }

            @SuppressWarnings("unchecked")
            private List<RequestMappingInfoHandlerMapping> getHandlerMappings(Object bean) {
                try {
                    Field field = ReflectionUtils.findField(bean.getClass(), "handlerMappings");
                    field.setAccessible(true);
                    return (List<RequestMappingInfoHandlerMapping>) field.get(bean);
                } catch (IllegalArgumentException | IllegalAccessException e) {
                    throw new IllegalStateException(e);
                }
            }
        };
    }
}

5、修改SpringSecurity的配置,配置swagger相关请求不需要认证。

5、SpringSecurity认证流程回顾

SpringSecurity提供了若干个过滤器,其中核心的过滤器有UsernamePasswordAuthenticationFilter、ExceptionTranslationFilter、FilterSecurityInterceptor。他们能够拦截所有Servlet请求,并将这些请求转给认证和访问决策管理器(注册到Spring容器的各种安全组件)处理,从而增强程序的安全性。

SpringSecurity的认证流程如下:

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第7张图片

牢记这个流程,后面代码我们也会根据这个流程进行功能的开发。

6、认证管理器AuthenticationManager准备

从认证流程的第3、第4步来看将用户名和密码封装为Authentication后需要调用认证管理器的authenticate()方法进行认证,因此Spring容器中需要注入认证管理器的bean实例。

为了代码书写规范我们把所有关于SpringSecurity的组件都写在SecurityConfiguration.java这个配置类上,例如

SecurityConfiguration.java

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
	...
    /**
     * 身份认证管理器,调用authenticate()方法完成认证
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
    ...
}

源码分析

AuthenticationManager是一个接口默认实现是ProviderManager。而ProviderManager只是最外层的认证入口,在这一层会获取所有可用的认证机制(AuthenticationProvider)以及异常处理等,真正的认证入口其实是AuthenticationProvider接口实现类下的authenticate()方法,详见以下代码

ProviderManager.java


public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
	...
	// 外层认证入口核心代码
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		// 获取所有可用的认证机制(当然我们这里没有配置别的认证机制,只有一种默认的DaoAuthenticationProvider)
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
			try {
				// **** 这里才是真正的认证入口 ****
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		...
	}
	...
}

仔细看AuthenticationProvider的结构(依赖了PasswordEncoderUserDetailsService两个bean实例,因此容器中也要注入这两个bean实例)

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第8张图片

7、密码加密器PasswordEncoder准备

DaoAuthenticationProvider源码得知,该结构依赖了PasswordEncoder实例bean,因此容器也要注入该类型的实例bean。

SecurityConfiguration.java

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
	...
    /**
     * 密码加密器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    ...
}

密码加密器除非有特殊需求,自己去定义。一般开发用BCryptPasswordEncoder就可以了,会动态维护盐值,每次都是随机的非常不错。

源码分析(密码是怎么匹配的后面章节再分析):

注意:DaoAuthenticationProvider继承了AbstractUserDetailsAuthenticationProviderauthenticate()方法是写在AbstractUserDetailsAuthenticationProvider上的,如下代码

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
	...
}

8、用户服务UserDetailsService准备

AuthenticationProvider的结构得知,该结构有个属性UserDetailsService,所以Spring容器中需要注入UserDetailsService的bean实例。当然从认证流程的第5、第6步来看,我们需要在UserDetailsService实例bean中重写loadUserByUsername()抽象方法,来实现用户的查找逻辑。

UserDetailsServiceImpl.java

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userService.getUserByName(username);
        if (null == user) {
            throw new RuntimeException(String.format("not found [%s]", username));
        }
        return new SecurityUser(user);
    }
}

注意:

为了代码规范我们把用户相关的操作,都放到了UserService上,因为用户操作不只是查询,还有新增等。新增时密码都是用密码加密器加密后存储的(后面分析为什么要存储密码加密器加密后的密文密码)。

另外loadUserByUsername()方法返回的类型为UserDetailsUserDetails是SpringSecurity内置的结构,没有我们自定义的信息(如电话号码、用户真实姓名、用户的头像地址、用户所属的行政区区划、用户其他信息等等)因此我们还需要自定义UserDetails的扩展结构,loadUserByUsername()方法返回的也是这个扩展结构,例如:

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/6/29 22:49
 **/
public class SecurityUser implements UserDetails {
	// 这是我们扩展的用户信息
    private UserEntity userEntity;

    public SecurityUser(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

    public UserEntity getUserEntity() {
        return userEntity;
    }

    public void setUserEntity(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

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

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

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

    @Override
    public boolean isAccountNonExpired() {
        // false 用户帐号已过期
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        // false 用户帐号已被锁定
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        // false 用户凭证已过期
        return true;
    }

    @Override
    public boolean isEnabled() {
        // false 用户已失效
        return true;
    }

}

9、MP集成与UserService准备

1、准备一张用户表

CREATE TABLE PR.`PR_USER` (
    `ID` BIGINT(10) UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
    `REGION_CODE` NVARCHAR(6) COMMENT '行政区代码',
    `PHONE` NVARCHAR(11) COMMENT '手机号',
    `CREATE_TIME` TIMESTAMP COMMENT '创建时间',
    `USERNAME` NVARCHAR(100) COMMENT '用户名',
    `PASSWORD` NVARCHAR(100) COMMENT '密码'
) ENGINE=INNODB CHARSET=utf8 COLLATE=utf8_general_ci;

2、用户表对应的实体

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/7/2 16:30
 **/
@TableName(value = "PR_USER", schema = "PR")
public class UserEntity implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField(value = "REGION_CODE")
    private String regionCode;
    private String phone;
    private Date createTime;
    private String username;
    private String password;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getRegionCode() {
        return regionCode;
    }

    public void setRegionCode(String regionCode) {
        this.regionCode = regionCode;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    public Date getCreateTime() {
        return createTime;
    }

    public void setCreateTime(Date createTime) {
        this.createTime = createTime;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

3、用户表对应的Mapper

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/7/2 17:14
 **/
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
}

4、操作用户表的servie核心代码

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/7/2 17:00
 **/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserEntity getUserByName(String username) {
        QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
        wrapper.eq("username", username);
        return this.getOne(wrapper);
    }

    @Override
    public UserEntity saveUser(UserEntity entity) {
        if (null == entity.getCreateTime()) {
            entity.setCreateTime(new Date());
        }
        entity.setPassword(passwordEncoder.encode(entity.getPassword()));
        UserEntity user = this.getUserByName(entity.getUsername());
        if (null == user) {
            this.save(entity);
            user = entity;
        } else {
            Long id = user.getId();
            BeanUtils.copyProperties(entity, user);
            user.setId(id);
            this.updateById(user);
        }
        return user;
    }
}

10、DaoAuthenticationProvider认证源码分析

准备好必要的组件后,我们再来看SpringSecurity的DaoAuthenticationProvider组件是如何进行认证的,前面也说了DaoAuthenticationProvider继承了AbstractUserDetailsAuthenticationProvider。所以看源码时两个要一起看。

AbstractUserDetailsAuthenticationProvider.java(注意看我注释的地方)

public abstract class AbstractUserDetailsAuthenticationProvider
		implements AuthenticationProvider, InitializingBean, MessageSourceAware {
	...
	// 认证核心代码
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
				() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
						"Only UsernamePasswordAuthenticationToken is supported"));
		String username = determineUsername(authentication);
		boolean cacheWasUsed = true;
		UserDetails user = this.userCache.getUserFromCache(username);
		if (user == null) {
			cacheWasUsed = false;
			try {
				// retrieveUser() 这个方法主要获取UserDetails
				// 调用 UserDetailsService 的 loadUserByUsername() 方法也就是上面我们重写的方法
				// 这个方法的实现不在当前类上而是在DaoAuthenticationProvider子类上
				// 所以具体实现逻辑应该去子类上看
				user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			}
			catch (UsernameNotFoundException ex) {
				this.logger.debug("Failed to find user '" + username + "'");
				if (!this.hideUserNotFoundExceptions) {
					throw ex;
				}
				throw new BadCredentialsException(this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
			}
			Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
		}
		try {
			this.preAuthenticationChecks.check(user);
			// additionalAuthenticationChecks() 这个方法主要是通过密码加密器对比UserDetails中的密码是否与Authentication中的密码是否一致
			// 这个方法的实现不在当前类上也是是在DaoAuthenticationProvider子类上
			// 所以具体实现逻辑应该去子类上看
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		catch (AuthenticationException ex) {
			if (!cacheWasUsed) {
				throw ex;
			}
			// There was a problem, so try again after checking
			// we're using latest data (i.e. not from the cache)
			cacheWasUsed = false;
			user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		this.postAuthenticationChecks.check(user);
		if (!cacheWasUsed) {
			this.userCache.putUserInCache(user);
		}
		Object principalToReturn = user;
		if (this.forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}
		return createSuccessAuthentication(principalToReturn, authentication, user);
	}
	...
}

子类DaoAuthenticationProvider.java

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
	...
	// 获取UserDetails核心代码
	@Override
	protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			// 注意看这里
			// 调用 UserDetailsService 的 loadUserByUsername() 方法也就是上面我们重写的方法
			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
			if (loadedUser == null) {
				throw new InternalAuthenticationServiceException(
						"UserDetailsService returned null, which is an interface contract violation");
			}
			return loadedUser;
		}
		catch (UsernameNotFoundException ex) {
			mitigateAgainstTimingAttack(authentication);
			throw ex;
		}
		catch (InternalAuthenticationServiceException ex) {
			throw ex;
		}
		catch (Exception ex) {
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
		}
	}

	...
	// 传入密码对比UserDetails中的密码是否一致核心代码
	@Override
	@SuppressWarnings("deprecation")
	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			this.logger.debug("Failed to authenticate since no credentials provided");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
		// 获取Authenticate中的密码也就是接口参数传入的密码
		// 注意这里获取到的密码是明文的
		String presentedPassword = authentication.getCredentials().toString();
		
		// this.passwordEncoder就是注入spring容器的密码加密器
		// 对比规则由密码加密器实现(所以对比逻辑我们应该去密码加密器看)
		if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}
	...
}

11、密码加密器的匹配源码分析

由上面分析得知匹配密码是否一致会调用密码加密器的matches()方法,传入Authenticate中的密码和UserDetails中的密码。

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);
	// 实际上我们自己也可以写匹配规则只需实现PasswordEncoder接口
	// 重写matches()抽象方法
	// 注册到spring容器中即可
	// 我们公司就是自己定义的匹配规则
    boolean matches(CharSequence rawPassword, String encodedPassword);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

BCryptPasswordEncodermatches()源码

// rawPassword Authenticate中的密码(明文)
// encodedPassword UserDetails中的密码(密文)
// 我们不用关系他是怎么实现的
// 只要知道Authenticate中的密码它会先进行encode然后再对比
public boolean matches(CharSequence rawPassword, String encodedPassword) {
    if (rawPassword == null) {
        throw new IllegalArgumentException("rawPassword cannot be null");
    } else if (encodedPassword != null && encodedPassword.length() != 0) {
        if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
            this.logger.warn("Encoded password does not look like BCrypt");
            return false;
        } else {
            return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
        }
    } else {
        this.logger.warn("Empty encoded password");
        return false;
    }
}

public static boolean checkpw(String plaintext, String hashed) {
	// 将Authenticate中的密码转成 byte 数组然后进行对比
    byte[] passwordb = plaintext.getBytes(StandardCharsets.UTF_8);
    return equalsNoEarlyReturn(hashed, hashpwforcheck(passwordb, hashed));
}

因此在数据库中我们存储的密码也是密文的,一定是经过同一个密码加密器encode()后的字符串才能匹配成功。

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
    @Autowired
    private PasswordEncoder passwordEncoder;
	...
    @Override
    public UserEntity saveUser(UserEntity entity) {
        if (null == entity.getCreateTime()) {
            entity.setCreateTime(new Date());
        }
       
		// 通过密码加密器加密   	
		entity.setPassword(passwordEncoder.encode(entity.getPassword()));
		
        UserEntity user = this.getUserByName(entity.getUsername());
        if (null == user) {
            this.save(entity);
            user = entity;
        } else {
            Long id = user.getId();
            BeanUtils.copyProperties(entity, user);
            user.setId(id);
            this.updateById(user);
        }
        return user;
    }
}

12、创建和解析JWT Token工具类封装

JWT全称是JSON Web Token,官网地址https://jwt.io是一款出色的分布式身份校验方案。可以生成token,也可以解析检验token,这里就不多说了,感兴趣的可以自行学习。

JwtUtil.java

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/4/3 14:03
 **/
public class JwtUtil {
    /**
     * 需要拦截的请求头信息
     */
    public static final String TOKEN_HEADER = "token";
    /**
     * 有效期
     */
    public static final Long JWT_EXPIRE_TIME = 60 * 60 * 1000L; // 1h
    /**
     * 加密算法
     */
    public static final String SIGN_ALGORITHMS = "AES";
    /**
     * jwt key
     */
    public static final String JWT_KEY = "security";

    /**
     * 获取token
     * @param id 唯一标识(盐值)
     * @param subject
     * @param expire
     * @return
     */
    public static String createToken(String id, String subject, Long expire) {
        JwtBuilder builder = getJwtBuilder(subject, expire, id);
        return builder.compact();
    }

    /**
     * 解析token
     * @param token
     * @return
     */
    public static Claims parseToken(String token) {
        SecretKey secretKey = generalKey();
        Claims body = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
        return body;
    }

    private static JwtBuilder getJwtBuilder(String subject, Long expire, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        Date date = new Date();
        if (expire == null) {
            expire = JWT_EXPIRE_TIME;
        }
        if (uuid == null) {
            uuid = getUUID();
        }
        Long expireTime = date.getTime() + expire;
        Date expireDate = new Date(expireTime);
        JwtBuilder builder = Jwts.builder()
                .setId(uuid) // 唯一标识
                .setSubject(subject) // 签名数据/主题
                .setIssuer(JWT_KEY) // 签发者
                .setIssuedAt(date) // 签发时间
                .signWith(signatureAlgorithm, secretKey) // 签名算法 + 秘钥
                .setExpiration(expireDate); // 过期时间
        return builder;
    }

    public static String getUUID() {
        return UUID.randomUUID().toString();
    }

    // 生成秘钥
    public static SecretKey generalKey() {
        byte[] encodeKey = Base64.getDecoder().decode(JWT_KEY);
        SecretKey secretKey = new SecretKeySpec(encodeKey, 0, encodeKey.length, SIGN_ALGORITHMS);
        return secretKey;
    }
}

13、登录获取token接口开发

我们规定客户端需要传入usernamepassword参数进行认证。认证成功后使用Redis缓存用户信息,并根据用户信息封装成token返回给客户端。

AuthController.java

/**
 * TODO
 *
 * @Description 认证API
 * @Author laizhenghua
 * @Date 2023/6/28 23:02
 **/
@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired
    private AuthService authService;

    @PostMapping("/login")
    public R login(@RequestBody JSONObject params) {
    	// 对于用户名/密码等敏感参数一律使用POST请求
        String username = params.getString("username");
        String password = params.getString("password");
        if (!StringUtils.hasText(username) || !StringUtils.hasText(password)) {
            return R.error(500, "用户名或密码为空!");
        }
        String token = authService.login(username, password);
        return R.success(token);
    }
}

AuthServiceImpl.java

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/6/29 22:07
 **/
@Service
public class AuthServiceImpl implements AuthService {
    Logger log = LoggerFactory.getLogger(getClass());
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private UserService userService;

    @Override
    public String login(String username, String password) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if (authenticate == null) {
            log.error("{username: {}, password: {}} 认证失败!", username, password);
            return null;
        }
        SecurityUser user = (SecurityUser) authenticate.getPrincipal();
        // userEntity
        UserEntity userEntity = user.getUserEntity();
        // 以用户表的行政区划代码作为盐值(这里主要是为了程序更好扩展实际开发中盐值可以是一些特殊或唯一的标识)
        String token = JwtUtil.createToken(userEntity.getRegionCode(), username, null);
        redisTemplate.opsForValue().set(String.format(RedisKey.AUTH_TOKEN_KEY, username),
                JSON.toJSONString(user), JwtUtil.JWT_EXPIRE_TIME, TimeUnit.MILLISECONDS);
        return token;
    }
}

关于Authentication接口的扩展知识

/*
getAuthorities() 权限信息列表,默认是GrantedAuthority接口的一些实现类,通常是代表权限信息的一系列字符串。
getCredentials() 密码信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。
getDetails() 细节信息,web应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的ip地址和sessionId的值。
getPrincipal() 最重要的身份信息,大部分情况下返回的是UserDetails接口的实现类,也是框架中的常用接口之一。
*/

14、拦截token的过滤器开发

过滤器的开发也是非常重要的一步,token的解析与在SecurityContextHolder中设置认证信息就是在过滤器中完成。

TokenFilter.java

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/7/2 19:51
 **/
@Component
public class TokenFilter extends OncePerRequestFilter {
    private Logger log = LoggerFactory.getLogger(getClass());
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private DemoConfiguration.Security security;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(JwtUtil.TOKEN_HEADER);
        log.info("intercept " + request.getRequestURI());

        // token=1用于swagger页面调用API
        /*if (!StringUtils.hasText(token) || "1".equals(token)) {
            filterChain.doFilter(request, response);
            return;
        }*/
        // 判断是否是放行请求
        if (isFilterRequest(request)) {
            filterChain.doFilter(request, response);
            return;
        }
        Claims claims = null;
        try {
            claims = JwtUtil.parseToken(token);
        } catch (Exception e) {
            log.error(e.getMessage());
            fallback("token解析失败(非法token)!", response);
            return;
        }
        String username = claims.getSubject();
        String cache = (String) redisTemplate.opsForValue().get(String.format(RedisKey.AUTH_TOKEN_KEY, username));
        if (cache == null) {
            fallback("token失效,请重新登录!", response);
            return;
        }
        SecurityUser user = JSON.parseObject(cache, SecurityUser.class);
        log.info(JSON.toJSONString(user, true));
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        // 放行
        filterChain.doFilter(request, response);
    }

    private void fallback(String message, HttpServletResponse response) {
        response.setCharacterEncoding("UTF-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        PrintWriter writer = null;
        try {
            if (message == null) {
                message = "403 Forbidden";
            }
            R res = R.error(403, message);
            writer = response.getWriter();
            writer.append(JSON.toJSONString(res));
        } catch (IOException e) {
            log.error(e.getMessage());
        } finally {
            if (writer != null) {
                writer.close();
            }
        }
    }

    private boolean isFilterRequest(HttpServletRequest request) {
        String contextPath = request.getContextPath();
        String filterPath = request.getRequestURI();
        List<String> permitAllPathList = security.getPermitAllPath();
        if (CollectionUtils.isEmpty(permitAllPathList)) {
            return false;
        }
        for (String path : permitAllPathList) {
            String pattern = contextPath + path;
            pattern = pattern.replaceAll("/+", "/");
            if (pathMatcher.match(pattern, filterPath)) {
                return true;
            }
        }
        return false;
    }
}

15、完整SpringSecurity的配置

/**
 * TODO
 *
 * @Description SecurityConfiguration
 * @Author laizhenghua
 * @Date 2023/6/25 21:55
 **/
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Autowired
    private AuthenticationExceptionHandler authenticationExceptionHandler;
    @Autowired
    private TokenFilter tokenFilter;
    @Autowired // 这个是当前项目Security模块的配置类(详见完整项目代码)
    private DemoConfiguration.Security security;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        List<String> permitAllPaths = security.getPermitAllPath();
        // 配置不需要认证的请求(这里所有的路径可以写在配置文件上修改时就不用改代码)
        if (!CollectionUtils.isEmpty(permitAllPaths)) {
            permitAllPaths.forEach(path -> {
                try {
                    http.authorizeHttpRequests().requestMatchers(path).permitAll();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
        // 关闭csrf因为不使用session
        http.csrf().disable()
                // 不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests()
                // 除了上面那些请求都需要认证
                .anyRequest().authenticated()
                .and()
                // 配置异常处理
                // 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
                // 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。
                .exceptionHandling()
                .authenticationEntryPoint(authenticationExceptionHandler);
        // 配置token拦截过滤器
        http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    /**
     * 身份认证管理器,调用authenticate()方法完成认证
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    /**
     * 密码加密器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

16、完整项目gitee地址

https://gitee.com/laizhenghua/spring-security

SpringSecurity6.0+Redis+JWT+MP基于token认证功能开发(源码级剖析可用于实际生产项目)_第9张图片

END

你可能感兴趣的:(JavaEE,安全,java)