Spring Security 框架篇-深入了解 Spring Security 的认证功能流程和自定义实现登录接口(实现自定义认证过滤器、登出功能)
使用 springboot 3.XX.XX无法导入 WebSecurityConfigurerAdapter 类( WebSecurityConfigurerAdapter 被当前版本的 spring 弃用了),将版本改查 2.X.X 就可以解决了(无需添加 spring-security-config,会引发依赖冲突,因为 starter-security 的子依赖包含 security-con
🔥博客主页: 【小扳_-CSDN博客】
❤感谢大家点赞👍收藏⭐评论✍
文章目录
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 的授权功能的介绍,会放到下一篇博客。
加油!
更多推荐
所有评论(0)