🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍
文章目录
1.0 Spring Security 框架概述
2.0 Spring Security 核心功能-认证功能
2.1 过滤器链
2.2 登录认证流程
2.3 思路分析
3.0 登录认证具体操作
3.1 环境搭建
3.2 实现 UserDetailService 接口
3.2.1 密码问题
3.3 自定义登录接口
3.3.1 实现登入功能
3.3.2 注意事项
3.4 认证过滤器
3.4.1 思路分析
3.4.2 具体实现
3.5 实现登出功能
1.0 Spring Security 框架概述
Spring Security 是一个强大且高度可定制的认证和访问控制框架,广泛用于 Java 和 Spring 应用程序中。它为应用程序提供了一层安全保护,帮助开发者管理用户身份验证、授权以及其他安全功能。
其核心功能:
1)认证(Authentication):
认证是验证用户身份的过程。Spring Security 支持多种认证机制,如使用用户名和密码的表单登录、OAuth2、LDAP、JWT等。开发者可以通过实现 UserDetailsService 接口来自定义用户信息的加载方式。
2)授权(Authorization):
授权是在用户通过认证后,根据该用户的权限来决定其能否访问特定资源或操作。Spring Security 提供了基于角色的访问控制(RBAC)和基于权限的访问控制(PBAC)两种方式。
3)过滤器链(Filter Chain):
Spring Security 使用过滤器链来处理请求。每个请求通过一系列过滤器进行处理,从而实现认证、授权、CSRF 保护等功能。开发者可以自定义过滤器,以满足特定的安全需求。
会话管理(Session Management):
Spring Security 提供了会话管理功能,可以控制用户会话的创建、并发控制,以及会话过期等。
4)密码加密:
框架提供了多种密码编码器,可以用来加密存储用户密码,确保密码的安全性。支持的编码器包括 BCrypt、SCrypt 和 PBKDF2 等。
2.0 Spring Security 核心功能-认证功能
在 SpringBoot 项目中使用 SpringSecurity 只需引入依赖,在访问项目资源的时候,就需要进行登录认证成功之后才能放行去访问资源。
1)先引入 Security 框架的依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
2)测试接口:
引入依赖后,尝试去访问接口就会自动跳转到一个 Spring Security 的默认登录页面,默认用户名是 user,密码会输出在控制台。
必须登录之后才能对接口进行访问。
密码:
在页面中登录:
登录之后:
登录之后,就可以访问到后台数据了。
因此,知道了 Spring Security 框架提供了认证、权限等核心功能,因此可以借助强大的 Spring Security 功能来实现一个自定义登录校验的功能,简单来说,就是在 Spring Security 框架中进行二次开发。
登录校验的流程图:
接下来,围绕着登录校验这个功能进行实现。
2.1 过滤器链
Spring Security 的原理其实就是一个过滤链,内部包含了提供各种功能的过滤器。
图中只展示了核心的过滤器,其他非核心的过滤器并没有在图中展示。
核心过滤器的功能介绍:
1)UsernamePasswordAuthenticationFilter:负责处理我们在登录页面填写了用户名与密码后的登录请求。
2)ExceptionTranslationFilter:处理过滤器中抛出的任何 AccessDeniedException 和 AuthenticationException 。简单来说,在认证过程中或者在权限校验过程中,抛出的异常都会被该过滤器进行捕获。
3)FilterSecurityInterceptor:负责权限校验的过滤器。
同时我们也可以通过Debug查看当前系统中 SpringSecurity 过滤器链中有哪些过滤器及它们的顺序。
2.2 登录认证流程
Spring Security 框架登录认证过程中所调用的接口:
根据需求来重写接口,从而来自定义实现登录认证功能。
Spring Security 在认证过程中主要是以下接口发挥了重要作用:
1)Authentication 接口:
该实现类,表示当前访问系统的用户,封装了用户相关信息。
2)AuthenticationMapper 接口:定义了认证 Authentication 的方法。
3)UserDetailsService 接口:加载用户特定数据的核心接口,里面定义了一个根据用户名查询用户信息的方法。
4)UserDetails 接口:提供核心用户信息。通过 UserDetailsService 根据用户名获取处理用户信息封装成 UserDetails 对象返回,然后将这些信息封装到 Authentication 对象中。
2.3 思路分析
现在知道了 Spring Security 框架中的相关接口,通过重写或者配置相关的接口,就可以实现自定义登录认证的功能。
大概的实现思路:
通过实现 UserDetailsService 接口,重写相关的方法,根据用户名 userName 来查询数据库中的信息,然后封装成一个 UserDetails 对象返回。
通过配置 AuthenticationMapper 来获取到该实现类,通过登录接口来使用该实现类,再通过 AuthenticationMapper 的实现类调用相关的方法进行往后执行,在数据库中拿到数据之后,再返回,判断是否拿到相关的数据,再来创建 jwt 返回给前端,将用户信息存放到 redis 中。
以上是大体的思路,下面进行详细讲述。
3.0 登录认证具体操作
3.1 环境搭建
1)数据库的搭建:
CREATE DATABASE IF NOT EXISTS db_security; -- ---------------------------- -- Table structure for sys_user -- ---------------------------- DROP TABLE IF EXISTS `sys_user`; CREATE TABLE `sys_user` ( `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键', `user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '用户名', `nick_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '昵称', `password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '密码', `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '账号状态(0正常 1停用)', `email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱', `phonenumber` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号', `sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)', `avatar` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像', `user_type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)', `create_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人的用户id', `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间', `update_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人', `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间', `del_flag` int(0) NULL DEFAULT 0 COMMENT '删除标志(0代表未删除,1代表已删除)', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of sys_user -- ---------------------------- INSERT INTO `sys_user` VALUES (1, 'xbaozi', '陈宝子', '$2a$10$WCD7xp6lxrS.PvGmL86nhuFHMKJTc58Sh0dG1EQw0zSHjlLFyFvde', '0', NULL, NULL, NULL, NULL, '1', NULL, NULL, NULL, NULL, 0);
2)相关依赖的引入:
<?xml version="1.0" encoding="UTF-8"?> <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 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.5</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>SpringSecurity</artifactId> <version>0.0.1-SNAPSHOT</version> <name>SpringSecurity</name> <description>SpringSecurity</description> <url/> <licenses> <license/> </licenses> <developers> <developer/> </developers> <scm> <connection/> <developerConnection/> <tag/> <url/> </scm> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter-test</artifactId> <version>3.0.3</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.glassfish.jaxb</groupId> <artifactId>jaxb-runtime</artifactId> <version>2.3.1</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
使用 springboot 3.XX.XX无法导入 WebSecurityConfigurerAdapter 类( WebSecurityConfigurerAdapter 被当前版本的 spring 弃用了),将版本改查 2.X.X 就可以解决了(无需添加 spring-security-config,会引发依赖冲突,因为 starter-security 的子依赖包含 security-config )
至于当前最新的 Spring Security 框架配置的使用,在之后的博客会讲解,先用最基础、最普遍的配置来了解 Spring Security 框架的使用。
3)yml 配置文件:
配置数据库的基本连接信息。
# 指定端口号 server: port: 8080 # 配置数据源 spring: application: name: security # 数据库连接池配置 datasource: url: jdbc:mysql://127.0.0.1:3306/db04?characterEncoding=utf8&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver # Redis配置 redis: # 这是我的虚拟机IP host: localhost port: 6379 password: 123456 # 操作0号数据库,默认有16个数据库 database: 0 jedis: pool: max-active: 8 # 最大连接数 max-wait: 1ms # 连接池最大阻塞等待时间 max-idle: 4 # 连接池中的最大空闲连接 min-idle: 0 # 连接池中的最小空闲连接 cache: redis: time-to-live: 1800000 # 设置数据过期时间为半小时(ms) mybatis: configuration: map-underscore-to-camel-case: true #配置驼峰自动转换 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句
4)实体类:
因为数据库只有一个表,因此我们只需要与之对应上就可以了。
@Data @AllArgsConstructor @NoArgsConstructor @TableName(value = "sys_user") @ToString public class User implements Serializable { private static final long serialVersionUID = 1L; /** 主键 */ @TableId private Long id; /** 用户名 */ private String userName; /** 昵称 */ private String nickName; /** 密码 */ private String password; /** 账号状态(0正常 1停用) */ private String status; /** 邮箱 */ private String email; /** 手机号 */ private String phonenumber; /** 用户性别(0男,1女,2未知) */ private String sex; /** 头像 */ private String avatar; /** 用户类型(0管理员,1普通用户) */ private String userType; /** 创建人的用户id */ private Long createBy; /** * 创建时间 */ private Date createTime; /** * 更新人 */ private Long updateBy; /** * 更新时间 */ private Date updateTime; /** 删除标志(0代表未删除,1代表已删除) */ private Integer delFlag; }
5)Redis 配置类:
主要是对 Redis 默认的序列化器进行一个更换。
@Configuration public class RedisConfig { @Bean @SuppressWarnings(value = { "unchecked", "rawtypes" }) public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class); // 使用StringRedisSerializer来序列化和反序列化redis的key值 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); // Hash的key也采用StringRedisSerializer的序列化方式 template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }
6)Jwt 工具类:
public class JwtUtil { // 设置有效期为60 * 60 *1000 一个小时 public static final Long JWT_TTL = 60 * 60 * 1000L; //设置秘钥明文 public static final String JWT_KEY = "xbaozi"; public static String getUUID() { String token = UUID.randomUUID().toString().replaceAll("-", ""); return token; } /** * 生成jtw * @param subject token中要存放的数据(json格式) */ public static String createJWT(String subject) { JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间 return builder.compact(); } /** * 生成jwt * @param subject token中要存放的数据(json格式) * @param ttlMillis token超时时间 */ public static String createJWT(String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间 return builder.compact(); } private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; SecretKey secretKey = generalKey(); long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); if (ttlMillis == null) { ttlMillis = JwtUtil.JWT_TTL; } long expMillis = nowMillis + ttlMillis; Date expDate = new Date(expMillis); return Jwts.builder() .setId(uuid) //唯一的ID .setSubject(subject) // 主题 可以是JSON数据 .setIssuer("sg") // 签发者 .setIssuedAt(now) // 签发时间 .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥 .setExpiration(expDate); } /** * 创建token */ public static String createJWT(String id, String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间 return builder.compact(); } /** * 生成加密后的秘钥 secretKey */ public static SecretKey generalKey() { byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); return key; } /** * 解析 * @throws Exception */ public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
7)Redis 工具类:
@SuppressWarnings(value = { "unchecked", "rawtypes" }) @Component public class RedisCache { @Autowired public RedisTemplate redisTemplate; /** * 缓存基本的对象,Integer、String、实体类等 * @param key 缓存的键值 * @param value 缓存的值 */ public <T> void setCacheObject(final String key, final T value) { redisTemplate.opsForValue().set(key, value); } /** * 缓存基本的对象,Integer、String、实体类等 * @param key 缓存的键值 * @param value 缓存的值 * @param timeout 时间 * @param timeUnit 时间颗粒度 */ public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) { redisTemplate.opsForValue().set(key, value, timeout, timeUnit); } /** * 设置有效时间 * @param key Redis键 * @param timeout 超时时间 * @return true=设置成功;false=设置失败 */ public boolean expire(final String key, final long timeout) { return expire(key, timeout, TimeUnit.SECONDS); } /** * 设置有效时间 * @param key Redis键 * @param timeout 超时时间 * @param unit 时间单位 * @return true=设置成功;false=设置失败 */ public boolean expire(final String key, final long timeout, final TimeUnit unit) { return redisTemplate.expire(key, timeout, unit); } /** * 获得缓存的基本对象。 * @param key 缓存键值 * @return 缓存键值对应的数据 */ public <T> T getCacheObject(final String key) { ValueOperations<String, T> operation = redisTemplate.opsForValue(); return operation.get(key); } /** * 删除单个对象 * @param key */ public boolean deleteObject(final String key) { return redisTemplate.delete(key); } /** * 删除集合对象 * @param collection 多个对象 */ public long deleteObject(final Collection collection) { return redisTemplate.delete(collection); } /** * 缓存List数据 * @param key 缓存的键值 * @param dataList 待缓存的List数据 * @return 缓存的对象 */ public <T> long setCacheList(final String key, final List<T> dataList) { Long count = redisTemplate.opsForList().rightPushAll(key, dataList); return count == null ? 0 : count; } /** * 获得缓存的list对象 * @param key 缓存的键值 * @return 缓存键值对应的数据 */ public <T> List<T> getCacheList(final String key) { return redisTemplate.opsForList().range(key, 0, -1); } /** * 缓存Set * @param key 缓存键值 * @param dataSet 缓存的数据 * @return 缓存数据的对象 */ public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet) { BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key); Iterator<T> it = dataSet.iterator(); while (it.hasNext()) { setOperation.add(it.next()); } return setOperation; } /** * 获得缓存的set * @param key * @return */ public <T> Set<T> getCacheSet(final String key) { return redisTemplate.opsForSet().members(key); } /** * 缓存Map * @param key * @param dataMap */ public <T> void setCacheMap(final String key, final Map<String, T> dataMap) { if (dataMap != null) { redisTemplate.opsForHash().putAll(key, dataMap); } } /** * 获得缓存的Map * @param key * @return */ public <T> Map<String, T> getCacheMap(final String key) { return redisTemplate.opsForHash().entries(key); } /** * 往Hash中存入数据 * @param key Redis键 * @param hKey Hash键 * @param value 值 */ public <T> void setCacheMapValue(final String key, final String hKey, final T value) { redisTemplate.opsForHash().put(key, hKey, value); } /** * 获取Hash中的数据 * @param key Redis键 * @param hKey Hash键 * @return Hash中的对象 */ public <T> T getCacheMapValue(final String key, final String hKey) { HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash(); return opsForHash.get(key, hKey); } /** * 删除Hash中的数据 * @param key * @param hkey */ public void delCacheMapValue(final String key, final String hkey) { HashOperations hashOperations = redisTemplate.opsForHash(); hashOperations.delete(key, hkey); } /** * 获取多个Hash中的数据 * @param key Redis键 * @param hKeys Hash键集合 * @return Hash对象集合 */ public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys) { return redisTemplate.opsForHash().multiGet(key, hKeys); } /** * 获得缓存的基本对象列表 * @param pattern 字符串前缀 * @return 对象列表 */ public Collection<String> keys(final String pattern) { return redisTemplate.keys(pattern); } }
8)astjson 对 Redis 工具类的配置。
这里有一个问题就是不要使用高版本的 fastjson 依赖,因为高版本的好像是已经将 ParserConfig.getGlobalInstance().setAutoTypeSupport(true); 去除了,从而后面导致报错。
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); } }
3.2 实现 UserDetailService 接口
通过实现 UserDetailService 接口,重写 loadUserByUsername 方法,从而实现根据 userName 查询数据库中的用户信息,将结果进行封装进行返回。
具体过程:
先实现 UserDetailService 接口,再进行重写 loadUserByUsername() 方法。
@Service public class MyUserDetailService implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //根据username查询数据库 User user = userMapper.selectByUserName(username); if (user == null){ throw new RuntimeException("用户不存在"); } //将查询到的结果user进行封装 return new LoginUser(user); } }
从数据库中,根据 username 获取用户信息,返回的 user 进行判断,如果为 null,直接抛出异常;如果不为 null,将 user 进行封装成 LoginUser 类。
封装的实体类 LoginUser:
@Data @NoArgsConstructor @AllArgsConstructor public class LoginUser implements UserDetails { /** 使用构造方法初始化 */ private User user; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUserName(); } /** 下面的方法暂时全部都让他们返回true */ @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
LoginUser 实现了 UserDetail 接口,重写了七个方法,当前只需要关注 getPassword() 获取用户的密码, getUsername() 获取用户名字的两个方法。
这就可以通过数据库中存在的用户进行登录认证了。
但是如果要测试,并且如果想让用户的密码是明文存储,需要在密码前加 {noop},如密码为 1234,那在数据库中的数据就得为 {noop}1234,这与默认使用的 PasswordEncoder 有关。
测试一下:
对以上的流程进行详细解析:
首先当前用户需要访问的路径为 "/hello",由于存在 Spring Security 框架,会对资源进行一种保护,也就是会进行验证,验证过程中先会来到 login 登录接口,再来到 ProvideManager 的 authentication() 方法进行认证,输入完用户名和密码之后,调用 DaoAuthenticationProvider 接口,再由该接口调用 UserDetailService 接口。
我们通过实现 UserDetailService 接口,重写 loadUserByUsername() 方法,来自定义的从数据库中查询用户信息,查询到数据之后,判断是否为 null,如果为 null,直接抛出异常,因为在 Spring Security 框架中的过滤链中有相关的异常捕获的过滤链 ExceptionTranslationFilter。如果不为 null,需要进行封装,用一个实现 UserDetails 接口的类进行封装 user 信息。
将 LoginUser 类进行返回给 DaoAuthenticationProvider 接口,由该接口来进行密码的校验,获取到 UserDetailsService 返回的信息后,通过 PasswordEncoder 进行密码校验。
3.2.1 密码问题
在 Spring Security 中默认使用的 PasswordEncoder 要求数据库中的密码格式为 {id}password,框架会根据前面的 id 去判断密码的加密模式,我们上面的 {noop}1234 也是属于这种格式,其中的 noop 就标明着这个密码是以明文的形式进行存储的,就会直接使用后面的 1234 当作密码。
而一般我们会使用 Spring Security 为我们提供的 BCryptPasswordEncoder,其操作也很简单,我们只需要把 BCryptPasswordEncoder 对象注入 Spring 容器中,Spring Security 就会使用我们自定义的 PasswordEncoder 进行密码校验。
配置 PasswordEncoder:
首先定义一个 SpringSecurity 的配置类。
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { /** * 将BCryptPasswordEncoder加入到容器中 **/ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }
这样就可以拿到 passwordEncoder 的 Bea 对象了,该对象中主要有两个方法:
1)passwordEncoder.encode("明文"):将明文进行加密。
2)passwordEncoder.matches("明文","使用 encode 加密的密文"):对明文和加密后的密文进行匹配,如果匹配,返回 true;如果不匹配,返回 false。
因此在 Spring Security 框架中,在登录认证校验密码的过程中,就会调用 passwordEncode.matches() 方法进行校验。
测试:
现在将数据库的明文 “1234” 使用 encode 方法替换成密文存放到数据库中:
访问 "/hello" 接口,先会自动跳转登录页面:
输入用户名、密码之后:
访问资源成功了。
3.3 自定义登录接口
通过自定义登录接口,实现密码验证成功之后,返回前端 JWT 令牌和将用户信息存放到 Redis 中的功能。
3.3.1 实现登入功能
首先定义登录接口,用户通过访问 "/login" 接口进行登录。
LoginController 控制层在 login 方法里面调用 loginService.login() 方法。
需要暴露 AuthenticationManager 登录认证管理器,由于旧版需要在配置类中,将其放入到 IOC 容器再进行获取,重写 AuthenticationManager 方法:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { private String[] matchers = new String[]{ "/userLogin" }; /** * 将BCryptPasswordEncoder加入到容器中 **/ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } }
在配置类中重写 authenticationManagerBean() 方法后,在该方法上加上 @Bean 注解,将其加入到 IOC 容器中,进行全部暴露。
LoginServiceIml 服务层:
@Service public class LoginServiceIml implements LoginService { @Autowired private RedisCache redisCache; @Autowired //获取到认证管理器 private AuthenticationManager authenticationManager; @Override public String login(User user) { //1. 封装用户名和密码为token UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword()); //2. 调用认证管理器进行认证,将token传进去 Authentication authenticate = authenticationManager.authenticate(authenticationToken); //3. 判断是否验证成功 if (Objects.isNull(authenticate)){ throw new RuntimeException("用户名或密码错误"); } //4. 认证成功,则获取到用户信息 LoginUser principal = (LoginUser) authenticate.getPrincipal(); //获取到用户信息 User getUser = principal.getUser(); //5. 生成JWT,将用户id作为载荷 String jwt = JwtUtil.createJWT(getUser.getId().toString()); //6. 将用户的信息存放到redis中,且设置时间为30分钟 redisCache.setCacheObject("login:"+getUser.getId(),principal,30, TimeUnit.MINUTES); //7. 最后将jwt返回 return jwt; } }
在服务层将 authenticationManager 对象从 IOC 容器进行引入,调用 authenticationManager 认证管理器的 authenticate() 方法且将用户的登录名字与密码进行封装再放入该方法中。
之后该方法就会进行下一步的调用,直到调用 UserDetailsService接口,从自定义中的 MyUserDetailService 接口从数据库中获取数据进行返回,直到回到 authenticate() 方法。
判断返回值是否为空,如果不为空,则证明密码校验正确,接着就可以创建 Jwt 令牌和将用户信息放入到 Redis 中。
在测试之前,还需要进行配置,当用户访问登录接口的时候,应该对自定义的 "/userLogin" 路径进行放行操作,而之前的 "/login" 路径 Spring Security 框架实现的,现在我们不需要用到该功能,而是自定义实现登录接口,可以更好的满足我们的需求。
因此还需要对 SecurityConfig 进行配置:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { private String[] matchers = new String[]{ "/userLogin" }; /** * 将BCryptPasswordEncoder加入到容器中 **/ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 对于登录接口 允许匿名访问 .antMatchers(matchers).anonymous() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); } }
3.3.2 注意事项
1)mybatis 的起步依赖版本不能太高,这里用 2.2.0 的版本。
2)由于当前版本比较低 Spring Boot 的版本为 2.5.5 版本,为什么用那么低的版本呢?这是因为在高版本的 Spring Boot 对 Spring Security 框架中的 WebSecurityConfigurerAdapter 类弃用了,在高版本不能使用了。
3)如果运行还是有问题,注意是否导入了相关的依赖,比如:
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.glassfish.jaxb</groupId> <artifactId>jaxb-runtime</artifactId> <version>2.3.1</version> </dependency>
4)出现 403 问题:
- 检查数据库存储的密码是否通过 PasswordEncoder 对象加密存储的,如果不是请调用 passwordEncoder.encode("1234");
- 检查 SecurityConfig 配置类是否放行了登录链接 http.authorizeRequests().antMatchers("/userLogin").anonymous();
- 请以 post 的请求方式并且请求头加上 Content-Type=application/json 的方式发送 JSON 格式 {"userName": "xbs", "password": "1234" };
- 检查控制层接收参数的时候,是否使用 @RequsetBody 注解。
- 检查发送请求的时候参数是否与 User 对象中的字段保持一致。
测试:
1)发送请求:
2)返回 Jwt 结果:
3)Redis 存放的结果:
3.4 认证过滤器
定义一个认证过滤器,用来对 Jwt 令牌进行校验,判断是否通过正确的登录后,才去访问的后台资源。
3.4.1 思路分析
主要流程:
1)定义过滤器并将其加入 Spring 容器中,因为后面需要将其插入到过滤器链中。
2)获取请求头中的 token 数据,判断请求头中是否携带 token 数据,若没有携带存在两种可能:
- 用户需要登录,正在访问登录接口准备账号密码登录。
- 用户未登录或登录过期,导致没有 token 或 token 过期。
3)解析 token 数据,从中拿到 userId;
4)从 Redis 中获取用户数据,若 Redis 中无数据则证明登录已过期,抛出异常提示;
5)将用户数据存入 SecurityContextHolder 容器中,因为这里是认证,是假设已经登录之后的状态,所以参数列表分别为用户数据,null,鉴权信息;如果是前面的还未登录状态,参数列表则为账号和密码两个参数。
6)将过滤器插入至过滤器链中。
3.4.2 具体实现
代码如下:
@Configuration public class MySecurityFilter extends OncePerRequestFilter { @Autowired private RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //先去尝试获取从请求头中获取token数据 String jwt = request.getHeader("token"); //判断token是否为null if (StringUtils.isNullOrEmpty(jwt)){ //这里有两种情况 //第一种就是正在去登录过程 //第二种就是token过期了 //因此直接放行即可,在SpringSecurity后续的过滤器中会进行判断 filterChain.doFilter(request,response); return; } //如果token不为null,则需要去解析token String userId; try { //解析token userId = JwtUtil.parseJWT(jwt).getSubject(); } catch (Exception e) { e.printStackTrace(); //解析失败,直接返回错误信息 throw new RuntimeException("token非法"); } //根据用户id去Redis中查找缓存信息 LoginUser loginUser = redisCache.getCacheObject("login:" + userId); if (Objects.isNull(loginUser)){ throw new RuntimeException("用户登录已过期,请重新登录"); } UsernamePasswordAuthenticationToken loginUserToken = new UsernamePasswordAuthenticationToken(loginUser,null,null); SecurityContextHolder.getContext().setAuthentication(loginUserToken); //放行 filterChain.doFilter(request,response); } }
为什么没有获取到 token 数据时还要放行的原因:
在过滤器链中,可以看到主要作用的有三个过滤器,其中可以简单的理解为第一个用于登录,第二个过滤器用来捕获异常的,第三个则用来从 SecurityContextHolder 中获取数据来鉴权。
因此,这里放行是为了可能后面的登录操作,不需要担心绕过认证,因为有第三个过滤器的存在。
因为如果是已登录的状态会在上面自定义的过滤器中将用户信息(内包含鉴权信息)存放至 SecurityContextHolder 中去,如果在该鉴权过滤器中没能成功从中拿到数据,那就证明该用户这次操作并不是登录操作,而是真正需要拦截的操作,因此就会在 FilterSecurityInterceptor 过滤器中抛出异常。
配置 SecurityConfig 类:
在完成认证过滤器之后,还需要进行配置让其过滤器操作起来。
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { private String[] matchers = new String[]{ "/userLogin" }; /** * 将BCryptPasswordEncoder加入到容器中 **/ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired private MySecurityFilter mySecurityFilter; @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(matchers).anonymous() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); // 参数列表分别为需要插入的过滤器和标识过滤器的字节码 http.addFilterBefore(mySecurityFilter, UsernamePasswordAuthenticationFilter.class); } }
首先引入 mySecurityFilter 认证过滤器,通过 http.addFilterBefore() 的方法,添加到 UsernamePasswordAuthenticationFilter 用户名密码登录器之前。
3.5 实现登出功能
从 SecurityContextHolder 中获取认证信息。因为在访问退出接口的时候,肯定是已经登录了且是经过了自定义的过滤器,因此在 SecurityContextHolder 中是已经存放了该登录用户的基本数据信息,这样我们就是可以获取得到的。
根据获取到的用户数据获取 useId 进行 key 的拼接,并从 Redis 中删除指定 key 的值,即删除该用户已登录的标识
代码如下:
@Override public String exit() { //首先从SecurityContextHolder中获取到用户信息 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); //其实这里不需要进行判断是否为空,因为如果为空,则直接返回null,不会进入到后面 //但是这里为了严谨,防止空指针异常 if (Objects.isNull(authentication)){ throw new RuntimeException("获取失败"); } LoginUser principal = (LoginUser) authentication.getPrincipal(); User user = principal.getUser(); //拿到用户id,删除redis中的缓存 redisCache.deleteObject("login:"+user.getId()); return "退出成功"; }
希望我的博客可以帮你解决问题,对于 Spring Security 的授权功能的介绍,会放到下一篇博客。
加油!