简介
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比 Shiro 丰富。
Spring Security是一个 功能强大且高度可定制 的,主要负责为 Java 程序提供声明式的身份验证和访问控制的安全框架。其前身是 Acegi Security ,后来被收纳为 Spring 的一个子项目,并更名为了 Spring Security。
Spring Security 的 底层主要是基于 Spring AOP 和 Servlet 过滤器 来实现安全控制 ,它提供了全面的安全解决方案,同时授权粒度可以在 Web 请求级和方法调用级来处理身份确认和授权。
SpringSecurity 核心功能
-
认证(Authentication): 用户身份验证(Authentication): 验证用户的身份,确保用户是其所声明的身份。 自定义认证逻辑: 允许开发者定义自己的身份验证逻辑,以适应特定的应用程序需求。
-
授权(Authorization): 访问控制(Access Control): 定义了用户对应用程序中受保护资源的访问权限。 角色和权限: Spring Security 支持基于角色和权限的授权机制,允许开发者定义用户的角色和相应的权限。
-
防护攻击(Protection against common attacks): 防止 CSRF 攻击: 提供了防止跨站请求伪造(CSRF)攻击的机制。
-
加密功能(Encryption Features): 密码加密: 提供了密码加密的支持,确保存储在数据库中的用户密码是安全的。
-
会话功能(Session Management): 会话跟踪: 跟踪用户会话状态,可以配置会话超时等相关属性。 会话固定保护: 防止会话固定攻击,确保用户会话的安全性。
-
RememberMe功能: Remember-Me认证: 允许用户选择在登录后保持持久的认证状态,避免在每次访问时都要重新登录。 Token 持久化: 使用 Remember-Me Token 来实现持久的认证状态。
目前最新的版本是 6.2.0,提供了许多新功能,需使用 JDK 17 及以上版本。
1、SpringSecurity6 版本 新特性
首先该文章所使用的技术版本为:
-
SpringBoot:3.1.5
-
SpringSecurity:6.1.5
-
JDK : 17
-
MySQL : 8.2.0
-
Mybatis-plus : 3.5.4
-
jwt / redis
接下来我介绍一下新特性
1. WebSecurityConfigurerAdapter
首先第一点就是在 SpringSecurity6.1 中 WebSecurityConfigurerAdapter 已经被完全移除了,用不了一点。
还有就是.and()也不能用了。而且配置类中要使用 Lambda 表达式来书写
2.配置 SecurityFilterChain
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
}
}
以后就要改写为下面这个写法:
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable());
//放行登录请求地址
http.authorizeHttpRequests(auth -> auth.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated());
return http.build();
}
3.关于 AuthenticationManager 的获取以前可以通过重写父类的方法来获取这个 Bean,类似下面这样:
public class SecurityConfig extends WebSecurityConfigurerAdapter {
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
现在要改为下面这个写法:
/**
* AuthenticationManager:负责认证的
* DaoAuthenticationProvider:负责将userDetailsService,PasswordEncoder融合进去
*/
public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder){
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
//关联加密编码器
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
//将daoAuthenticationProvider放进ProviderManager中,包含进去
ProviderManager providerManager = new ProviderManager(daoAuthenticationProvider);
return providerManager;
}
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
除了上面的方法外还能从 HttpSecurity 提取出 AuthenticationManager
public class SpringSecurityConfiguration {
AuthenticationManager authenticationManager;
UserDetailsService userDetailsService;
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
authenticationManagerBuilder.userDetailsService(userDetailsService);
authenticationManager = authenticationManagerBuilder.build();
return http.build();
}
}
2、入门示例搭建(基于内存来实现,并非数据库)
2.1 项目依赖
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.moon</groupId>
<artifactId>db_SpringSecurity</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>db_SpringSecurity</name>
<description>db_SpringSecurity</description>
<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.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
2.2 创建一个 web 访问接口
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
public class HelloController {
"/hello") (
public String hello() {
return "hello spring security";
}
}
-
运行项目之后控制台会打印出密码(密码是由 UUID 生成的),页面会自动跳转到
/login
地址,在 SpringBoot 中应遵循约定大于配置
的规则,项目加载了 SpringSecurity 依赖包就会自动安全限制访问,用户名默认为user
。
2.3 配置 Spring Security 账户密码
从上面的源码分析可知,默认的登录密码是利用 UUID 生成的随机字符串,很明显如果我们使用这个字符串作为登录密码,就太麻烦了。那么有没有更方便的登录账户呢?
Spring Security 框架允许我们自己配置用户名和密码,并且提供了 2 种方式来进行自定义用户名和密码:
-
在配置文件中定义
-
在配置类中定义
2.3.1 在配置文件中定义
在application.yml
配置文件新增以下内容:
spring
security
user
name admin
password123456
2.3.2 在配置类中定义(基于内存)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* 使用的是 SpringSecurity6.1.5,配置类有一些变化
* 1、该类不再需要继承其他的Security定义的类
* 2、需要使用 @Configuration 才会被Spring容器加载
* 3、废弃了很多方法,比如and()方法,建议使用Lambda表示实现
*/
// 标记为一个Security类,启用SpringSecurity的自定义配置
public class SecurityConfig {
// 自定义用户名和密码
// UserDetailsService:根据用户名加载用户,找到的话返回用户信息【UserDetails类型】
// UserDetails:存储了用户的信息
public UserDetailsService userDetailsService() {
// 定义用户信息
// 构建管理员
UserDetails adminUser = User.withUsername("admin")
.password("$2a$10$csvUZnj/VG6wBkooT/mewO.WbJesVCiHqEoWTyQrOYTKJvk3xpQb6")
.roles("admin", "user")
.build();
// 构建普通用户
UserDetails vipUser = User.withUsername("user")
.password("$2a$10$csvUZnj/VG6wBkooT/mewO.WbJesVCiHqEoWTyQrOYTKJvk3xpQb6")
.roles("user")
.build();
// 将用户存储到SpringSecurity中
InMemoryUserDetailsManager userDetailsManager = new
InMemoryUserDetailsManager();
// 创建两个用户,SpringSecurity在运行时就会知道有两个用户
userDetailsManager.createUser(adminUser);
userDetailsManager.createUser(vipUser);
return userDetailsManager;
}
}
UserDetailsService:提供查询用户功能,如根据用户名查询用户,并返回 UserDetails UserDetails:记录用户信息,如用户名,密码,权限等
SpringSecurity 提供密码加密工具:PasswordEncoder,具体实现又很多,此处使用BCryptPasswordEncoder
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
密码加密
String pass = "123456"; String result = passwordEncoder.encode(pass);
密码匹配我们可以使用 matches 方法
boolean isTrue = passwordEncoder.matches("111111",result); System.out.println("isTrue===>" + isTrue);
3、认证
3.1 登录认证校验流程(包含 jwt)
SpringSecurity 的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
3.1.1 认证流程讲解
Authentication 接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager 接口:定义了认证 Authentication 的方法
UserDetailsService 接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails 接口:提供核心用户信息。通过 UserDetailsService 根据用户名获取处理的用户信息要封装成 UserDetails 对象返回。然后将这些信息封装到 Authentication 对象中。
3.1.2 SpringSecurity 完整流程
SpringSecurity 的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
图中展示 UsernamePasswordAuthenticationFilter 过滤器:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。 当然还有别的过滤器
eg: ExceptionTranslationFilter: 处理过滤器链中抛出的任何 AccessDeniedException 和 AuthenticationException FilterSecurityInterceptor: 负责权限校验的过滤器。
4、授权
4.1 授权的作用:
-
一个后台管理系统中有很多不同的角色,每个不同的角色肯定对应不同的功能,然后角色也对应着不同的用户,这就是授权要去实现的效果,而且我们后面要去实现动态权限。也是根据这个思路进行实现的,采用 RBAC 权限模型
-
用户认证之后,会去存储用户对应的权限,并且给资源设置对应的权限,SpringSecurity 支持两种粒度 的权限
-
基于请求的:在配置文件中配置路径,可以使用**的通配符
-
基于方法的:在方法上使用注解实现
-
动态权限:用户权限被修改之后,不需要用户退出,会自动刷新,也不需要修改代码
-
4.1.1 基于方法鉴权
在 SpringSecurity6 版本中@EnableGlobalMethodSecurity 被弃用,取而代之的是@EnableMethodSecurity。默认情况下,会激活 pre-post 注解,并在内部使用 AuthorizationManager。
4.1.2 新老 API 区别
此@EnableMethodSecurity 替代了@EnableGlobalMethodSecurity。提供了以下改进:
-
使用简化的 AuthorizationManager。
-
支持直接基于 bean 的配置,而不需要扩展 GlobalMethodSecurityConfiguration
-
使用 Spring AOP 构建,删除抽象并允许您使用 Spring AOP 构建块进行自定义
-
检查是否存在冲突的注释,以确保明确的安全配置
-
符合 JSR-250
-
默认情况下启用@PreAuthorize、@PostAuthorize、@PreFilter 和@PostFilter
4.2 请求级别和方法级别对比
请求级别 | 方法级别 | |
---|---|---|
授权类型 | 粗细度 | 细粒度 |
配置位置 | 在配置类中配置 | 在方法上配置 |
配置样式 | DSL | 注解 |
授权定义 | 编程式 | SpEL 表达式 |
主要的权衡似乎是您希望您的授权规则位于何处。重要的是要记住,当您使用基于注释的方法安全性 时,未注释的方法是不安全的。为了防止这种情况,请在 HttpSecurity 实例中声明一个兜底授权规则。
如果方法上也定义了权限,则会覆盖类上的权限
注意:使用注解的方式实现,如果接口的权限发生变化,需要修改代码了。后期会学习动态权限,无需 修改代码就可以实现接口权限的修
5、集成数据库实现认证和授权(实践了!!)
其中简单的流程文章上面就不放了,eg:创建 Maven 项目、配置 mysql、创建 service.....等
-
数据库
CREATE TABLE `ums_sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT
NULL COMMENT '用户账号',
`nickname` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT
NULL COMMENT '用户昵称',
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT
'' COMMENT '用户邮箱',
`mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT
'' COMMENT '手机号码',
`sex` int DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
`avatar` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT
'' COMMENT '头像地址',
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
DEFAULT '' COMMENT '密码',
`status` int DEFAULT '0' COMMENT '帐号状态(0正常 1停用)',
`creator` bigint DEFAULT '1' COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`updater` bigint DEFAULT '1' COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT
NULL COMMENT '备注',
`deleted` tinyint DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='后台用户表';
-
创建实体类(实体类需要实现 UserDetails,是 SpringSecurity 的)
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Collection;
// @Data注解可以自动生成getter、setter、toString
// SpringSecurity会将认证的用户信息存储到UserDetails中
"ums_sys_user") (
public class SysUser implements Serializable, UserDetails {
private Long id;
private String username;
private String nickname;
private String email;
private Integer sex;
private String avatar;
private String password;
private Integer status;
private Long creator;
private Long updater;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Integer deleted;
private String remark;
//权限信息
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
public String getPassword() {
return password;
}
public String getUsername() {
return username;
}
public boolean isAccountNonExpired() {
return status == 0;
}
public boolean isAccountNonLocked() {
return status == 0;
}
public boolean isCredentialsNonExpired() {
return status == 0;
}
public boolean isEnabled() {
return status == 0;
}
}
5.1 认证实现流程
-
创建一个 UserDetailsService 实现 SpringSecurity 的 UserDetailsService 接口
-
写的是查询用户信息的接口
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
-
-
通过配置类对 AuthenticationManager 与自定义的 UserDetailsService 进行关联
-
SpringSecurity 是通过 AuthenticationManager 实现的认证,会判断用户名和密码对不对
-
-
在登录方法所在的类中注入 AuthenticationManager,调用 authenticate 实现认证逻辑
-
认证之后返回认证后的用户信息
SysUserDetailsService:
public class SysUserDetailsService implements UserDetailsService {
required = false) (
private SysUserMapper sysUserMapper;
/**
* 根据用户名去查询用户,如果没有查询到则会抛出异常 UsernameNotFoundException
* 返回UserDetails,SpringSecurity定义的类,用来存储用户信息
* SysUser实现了UserDetails接口,根据多态,他就是一个UserDetails
*/
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//查询用户信息
SysUser sysUser = sysUserMapper.selectUserByUsername(username);
log.info("SysUser---->>{}",sysUser);
// TODO 后面可以实现对权限、角色的查询,权限查询出来后设置进实体类中
}
return sysUser;
}
}
LoginController:
package com.moon.controller;
import com.moon.domain.dto.SysUserDto;
import com.moon.service.SysUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
/**
* @author:Y.0
* @date:2023/11/20
*/
"/auth") (
public class LoginController {
private SysUserService userService;
/**
* 登录方法:返回一个token令牌给前端
* 用户再次访问的时候,在请求头header中携带token
* SysUserDto 中只含有username,password字段
*/
"/login") (
public String doLogin( SysUserDto userDto){
String token = userService.doLogin(userDto);
return token ;
}
}
SysUserServiceImpl:
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
implements SysUserService{
required = false) (
private AuthenticationManager authenticationManager;
public String doLogin(SysUserDto userDto) {
// 传入用户名和密码 将是否认证标记设置为false
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDto.getUsername(),userDto.getPassword());
//实现登录逻辑此时会去调用loadUserByUsername
//返回的Authentication其实就是UserDetails
Authentication authenticate = null;
try {
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
} catch (BadCredentialsException e) {
throw new ServiceException("密码不正确");
} catch (UsernameNotFoundException e) {
throw new ServiceException("用户名不存在");
} catch (Exception e) {
throw new ServiceException(e.getMessage());
}
SysUser sysUser = (SysUser) authenticate.getPrincipal();
if (sysUser == null){
return "用户名或密码错误";
}
// 生成一个token,返回给前端
String token = UUID.randomUUID().toString().replaceAll("-", "")
return token;
}
}
SecurityConfig:
import com.moon.service.impl.SysUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* @EnableMethodSecurity开启方法权限控制类 @PreAuthorize
*
* @author:Y.0
* @date:2023/11/20
*/
public class SecurityConfig {
private SysUserDetailsService userDetailsService;
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable());
//放行登录请求地址
http.authorizeHttpRequests(auth -> auth.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated());
return http.build();
}
/**
* AuthenticationManager:负责认证的
* DaoAuthenticationProvider:负责将userDetailsService,PasswordEncoder融合进去
*
* @param passwordEncoder
* @return
*/
public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder){
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
//关联加密编码器
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
//将daoAuthenticationProvider放进ProviderManager中,包含进去
ProviderManager providerManager = new ProviderManager(daoAuthenticationProvider);
return providerManager;
}
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
5.2 认证原理
SpringSecurity 的认证是通过 AuthenticationManager 的 authenticate 方法实现,该方法接收一个 Authentication 对象,通过也返回一个 Authentication 对象,Authentication 中存储用户的主体【账 号】、密码、权限等信息。 同时 AuthenticationManager 是一个接口,上述的例子是通过他的常用实现类 ProviderManager 实现 的,所以看明白 ProviderManager 的 authenticate 方法就可以了。
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDto.getUsername(),userDto.getPassword());
首先上述代码是构建一个 Authentication 对象。通过 UsernamePasswordAuthenticationToken 这个实 现类。该类主要是将用户名和密码读取进来,并进行存储,并设置认证标记为 false。具体的认证代码如 下:
Authentication authenticate =authenticationManager.authenticate(authentication);
首先进入 ProviderManager 类中,执行 authenticate 方法,方法最后将用户返回
接下来看 result = provider.authenticate(authentication)执行,具体执行的是AbstractUserDetailsAuthenticationProvider
类中的方法
retrieveUser 方法的逻辑,具体也是执行 loadUserByUsername 方法,去根据用户名查找用户信息
-
整个逻辑梳理:
-
UsernamePasswordAuthenticationToken 将用户填写的用户名密码存储下来,设置认证状态为 false,它就是一个 Authentication 类型的对象
-
调用 AuthenticationManager.authenticate(Authentication authentication)进行用户认证,并返回 Authentication,其中记录用户信息和认证状态
-
1、首先执行 loadUserByUsername 方法,根据用户名获取用户
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
2、 再判断用户状态
this.preAuthenticationChecks.check(user);
public void check(UserDetails user) {
//检查用户是否锁定
if (!user.isAccountNonLocked()) {
this.logger.debug("Failed to authenticate since user account is locked");
throw new LockedException(
this.messages.getMessage("AccountStatusUserDetailsChecker.locked", "User account is locked"));
}
//检查用户是否可用
if (!user.isEnabled()) {
this.logger.debug("Failed to authenticate since user account is disabled");
throw new DisabledException(
this.messages.getMessage("AccountStatusUserDetailsChecker.disabled", "User is disabled"));
}
//查询用户是否过期
if (!user.isAccountNonExpired()) {
this.logger.debug("Failed to authenticate since user account is expired");
throw new AccountExpiredException(
this.messages.getMessage("AccountStatusUserDetailsChecker.expired", "User account has expired"));
}
//查询用户密码是否锁定
if (!user.isCredentialsNonExpired()) {
this.logger.debug("Failed to authenticate since user account credentials have expired");
throw new CredentialsExpiredException(this.messages
.getMessage("AccountStatusUserDetailsChecker.credentialsExpired", "User credentials have expired"));
}
}
3、 再判断密码是否正确
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
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"));
}
//获取用户输入的密码
String presentedPassword = authentication.getCredentials().toString();
//匹配密码是否正确使用matches
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"));
}
}
4、如果失败则重试,最终错误抛出异常,正确则返回 Authentication
5.3 授权(首先可用先了解下 RBAC 权限模型)
-
RBAC【Role-based access control】,增加了角色的概念,即基于角色的访问控制,将权限分配给角 色,再将角色分配给用户。简单来说,就是【用户关联角色,角色关联权限】。
RBAC 遵循三条安全原则:
-
最小权限原则:给角色配置最小但能满足使用需求的权限。
-
责任分离原则:给比较重要或者敏感的事件设置不同的角色,不同的角色间是相互约束的,由其一 同参与完成。
-
数据抽象原则:每个角色都只能访问其需要的数据,而不是全部数据,不同的角色能访问到的数据 也不同。
-
角色表
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色id',
`role_label` varchar(255) DEFAULT NULL COMMENT '角色标识',
`role_name` varchar(255) DEFAULT NULL COMMENT '角色名字',
`sort` int DEFAULT NULL COMMENT '排序',
`status` int DEFAULT NULL COMMENT '状态:0:可用,1:不可用',
`deleted` int DEFAULT NULL COMMENT '是否删除:0: 未删除,1:已删除',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci
-
权限表
CREATE TABLE `sys_menu` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`parent_id` bigint NOT NULL DEFAULT '0' COMMENT '父id',
`menu_name` varchar(255) DEFAULT NULL COMMENT '菜单名',
`sort` int DEFAULT '0' COMMENT '排序',
`menu_type` int DEFAULT NULL COMMENT '类型:0,目录,1菜单,2:按钮',
`perms` varchar(255) DEFAULT NULL COMMENT '权限标识',
`icon` varchar(255) DEFAULT NULL COMMENT '图标',
`deleted` int DEFAULT NULL COMMENT '是否删除',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
-
用户角色表
CREATE TABLE `sys_user_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
`user_id` bigint NOT NULL COMMENT '用户id',
`role_id` bigint NOT NULL COMMENT '角色id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-
角色权限表
CREATE TABLE `sys_role_menu` (
`id` bigint NOT NULL AUTO_INCREMENT,
`role_id` bigint DEFAULT NULL,
`menu_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
1、SpringSecurity 权限认证 SpringSecurity 要求将身份认证信息存到 GrantedAuthority 对象列表中。代表了当前用户的权限。 GrantedAuthority 对象由 AuthenticationManager 插入到 Authentication 对象中,然后在做出授权决策 时由 AccessDecisionManager 实例读取。
AuthorizationManager 实例通过该方法来获得 GrantedAuthority。通过字符串的形式表示, GrantedAuthority 可以很容易地被大多数 AuthorizationManager 实现读取。如果 GrantedAuthority 不 能精确地表示为 String,则 GrantedAuthorization 被认为是复杂的,getAuthority()必须返回 null 2、权限流程
-
登陆的时候需要查询用户权限,基于 RBAC 模型实现的权限设计
-
先获取用户的角色,根据角色获取用户权限
-
因为 SpringSecurity 识别权限的数据类型时 String,所以我们需要将查询出的权限对象,封装 到 String 类型的集合中【Set 集合,数据不可重复】
-
-
后续操作的时候,需要携带登陆的标记【token 或者 cookie】,根据 token 获取用户的信息,在后 续的操作过程中继续识别权限
-
后续的操作尽量不要每访问一次就查一次数据库,对数据库压力很大
-
-
用户实体类
private Integer delFlag;
//角色信息
private Set<SysRole> roleSet = new HashSet<>();
//权限信息
private Set<String> perms = new HashSet<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (perms != null && perms.size() > 0){
//将权限类型转换并告知SpringSecurity,将Set<String>转为Collection<GrantedAuthority>,通过lambda表达式来实现
return perms.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
}
return null;
}
-
剩下两个实体类和数据库字段对应就可以了,不做修改
查询用户权限信息(完成前面我们写的那个 TODO)
public class SysUserDetailsService implements UserDetailsService {
required = false) (
private SysUserMapper sysUserMapper;
required = false) (
private SysMenuMapper sysMenuMapper;
/**
* 根据用户名去查询用户,如果没有查询到则会抛出异常 UsernameNotFoundException
* 返回UserDetails,SpringSecurity定义的类,用来存储用户信息
* SysUser实现了UserDetails接口,根据多态,他就是一个UserDetails
*/
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//查询用户信息
SysUser sysUser = sysUserMapper.selectUserByUsername(username);
log.info("SysUser---->>{}",sysUser);
if (sysUser != null){
Set<SysRole> sysRoleSet = sysUser.getRoleSet();
Set<Long> roleIds = new HashSet<>();
for (SysRole sysRole : sysRoleSet) {
roleIds.add(sysRole.getRoleId());
}
//权限的查询
Set<String> permsSet = sysUser.getPerms();
Set<SysMenu> menus = sysMenuMapper.selectMenuByRoleId(roleIds);
for (SysMenu menu : menus) {
String perms = menu.getPerms();
//把权限添加到用户中
permsSet.add(perms);
}
log.info("权限---->>{}",sysUser);
}
return sysUser;
}
}
-
查询角色 sql
<mapper namespace="com.moon.mapper.SysUserMapper">
<resultMap id="BaseResultMap" type="com.moon.domain.entity.SysUser">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="userName" column="user_name" jdbcType="VARCHAR"/>
<result property="nickName" column="nick_name" jdbcType="VARCHAR"/>
<result property="password" column="password" jdbcType="VARCHAR"/>
<result property="status" column="status" jdbcType="CHAR"/>
<result property="email" column="email" jdbcType="VARCHAR"/>
<result property="phonenumber" column="phonenumber" jdbcType="VARCHAR"/>
<result property="sex" column="sex" jdbcType="CHAR"/>
<result property="avatar" column="avatar" jdbcType="VARCHAR"/>
<result property="createBy" column="create_by" jdbcType="BIGINT"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<result property="updateBy" column="update_by" jdbcType="BIGINT"/>
<result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
<result property="delFlag" column="del_flag" jdbcType="INTEGER"/>
<collection property="roleSet" resultMap="RoleMap"/>
</resultMap>
<resultMap id="RoleMap" type="com.moon.domain.entity.SysRole">
<id property="roleId" column="role_id"/>
<result property="roleLabel" column="role_label" />
<result property="roleName" column="role_name"/>
<result property="sort" column="sort" />
<result property="status" column="status" />
<result property="deleted" column="deleted" />
<result property="remark" column="remark"/>
<result property="createTime" column="create_time" />
<result property="updateTime" column="update_time"/>
</resultMap>
<select id="selectUserByUsername" resultMap="BaseResultMap">
SELECT
a.id,
a.user_name,
a.nick_name,
a.`password`,
a.email,
a.`status`,
a.phonenumber,
a.sex,
a.avatar,
a.create_by,
a.create_time,
a.update_by,
a.update_time,
a.del_flag,
c.role_id,
c.role_label,
c.role_name,
c.sort,
c.`status`,
c.deleted,
c.remark,
c.create_time,
c.update_time
FROM
sys_user AS a
left JOIN
sys_user_role AS b
ON
a.id = b.user_id
left JOIN
sys_role AS c
ON
b.role_id = c.role_id
where a.del_flag = 0 and c.deleted = 0 and a.user_name = #{username}
</select>
</mapper>
查询权限 sql
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.moon.mapper.SysMenuMapper">
<resultMap id="BaseResultMap" type="com.moon.domain.entity.SysMenu">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="parentId" column="parent_id" jdbcType="BIGINT"/>
<result property="menuName" column="menu_name" jdbcType="VARCHAR"/>
<result property="sort" column="sort" jdbcType="INTEGER"/>
<result property="menuType" column="menu_type" jdbcType="INTEGER"/>
<result property="perms" column="perms" jdbcType="VARCHAR"/>
<result property="icon" column="icon" jdbcType="VARCHAR"/>
<result property="deleted" column="deleted" jdbcType="INTEGER"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
</resultMap>
<select id="selectMenuByRoleId" resultType="com.moon.domain.entity.SysMenu">
select m.id,
m.parent_id,
m.menu_name,
m.sort,
m.menu_type,
m.perms,
m.icon,
m.deleted,
m.create_time,
m.update_time
from sys_menu m left join sys_role_menu rome on m.id = rome.menu_id
where rome.role_id in
<foreach collection="roleIds" open="(" close=")" separator="," item="roleId">
#{roleId}
</foreach>
</select>
</mapper>
5.4 携带登录信息(jwt 令牌 token)
HTTP 协议是无状态请求,即一次会话不会记录上一次会话的内容。所以需要通过携带数据的方式告知服 务器我是谁,以便服务器知道这个人有没有权限访问这个数据。最初使用 cookie 的方式携带
-
cookie Cookie,有时也用其复数形式 Cookies。类型为“小型文本文件”,是某些网站为了辨别用户身份,进行 Session 跟踪而储存在用户本地终端上的数据(通常经过加密),由用户客户端计算机暂时或永久保存 的信息 。身份信息,订单信息,浏览记录,这样做 cookie 会越来越大,之后就出现 session 了
-
session 考虑将用户的信息存储到服务器端,请求时只携带可以识别用户身份的身份信息就可以了,再根据用户 身份获取用户的其他资料比如:实名信息,订单信息,访问记录等
-
首先是用户登录,服务端会生成一个 session,再生成该 session 的唯一标识 sessionId,这个 sessionId 可以得知是哪个用户,再将此 sessionId 通过 cookie 告知客户端
-
之后客户端的请求都再 cookie 中携带这个 sessionId,服务端获取 sessionId,找到对应的用户,再 去处理请求就可以了 session 的作用就是缩小了 cookie 的体积,并且 cookie 存储在客户端,session 存储在服务端。 随着网民增长,软件用户增加,服务器会采用分布式,集群等方式部署,保障可以为更多用户提供服 务,session 就会出现问题。即 session 如果在 A 服务器生成,后续请求如果发送到 B 服务器,则 B 服务器就无法得知是哪个用户
-
token【令牌】 作用:其实就是为了知道你是谁。还有一种方式就是将用户信息加密变成字符串,直接发给客户端,客 户端后边请求都携带这个加密字符串,我们称之为 Token【令牌】,服务端拿到字符串之后可以解密获 取到用户信息,这样就不需要查询数据库了。多个服务端加密算法相同,也就可以完成解密工作,这个 生成 Token 的技术可以选用 JWT【Json Web Tokens】。而且这个字符串我们会放到请求头【header】 中,这个字段根据自己的需求定义 JWT 可以将用户信息【id,username,avatar,权限。密码,支付密码千万别放】变成字符串,也可以 加密。之后再次请求时携带这个字符串,可以解析为用户信息。就不用访问数据库了
重点:
-
token:token 是一种识别用户身份的机制,基于这种机制可以有很多种实现,就是在登陆完成之 后,再请求头中添加用户标识。放到请求头中,再次访问的时候,服务器从请求头中获取 token, 判断用户是否是合法的
-
jwt:就是生成 token 的一种具体实现,它可以直接将用户信息存储到字符串中,也可以根据字符串 解析出用户信息,不需要再查询数据库了。也支持加密
5.4.1 JWT 结构
JWT 是一个很长的字符串,由三部分组成。但是 JWT 内部并没有换行,而是由.隔开
eyJhbGciOiJIUzI1NiJ9. eyJpZCI6MTAwMCwiYXZhdGFyIjoi5p2p5qyQ5qe45raT77-95raT7oGE44GU6Y2N5b-T5rm06Y2n77- 9IiwidXNlcm5hbWUiOiLlr67nirHnrIEifQ. 9sIcaoCsaT-WXMqLSPVtFHj_zKh6OpwEboUF_Qit6G4
-
Header(头部) Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子
{ "alg": "HS256", "typ": "JWT" } alg:表示签名使用的算法,默认为 HMAC SHA256(写为 HS256) typ:表示令牌的类型,JWT 令牌统一写为 JWT。 最后,使用 Base64 URL 算法将上述 JSON 对象转换为字符串保存。
-
Payload(负载) Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了 7 个官方字段,供选用
-
iss (issuer):签发人/发行人
-
sub (subject):主题
-
aud (audience):用户
-
exp (expiration time):过期时间
-
nbf (Not Before):生效时间,在此之前是无效的
-
iat (Issued At):签发时间
-
jti (JWT ID):用于标识该 JWT 除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。
{ "sub": "1234567890", "name": "John Doe", "admin": true } 注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。 这个 JSON 对象也要使用 Base64URL 算法转成字符串。
-
-
Signature(签名) Signature 部分是对前两部分的签名,防止数据篡改。 首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。 算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.) 分隔,就可以返回给用户。
-
Base64URL 前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有 一些小的不同。 JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。 Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成 _ 。这就是 Base64URL 算法
5.4.2 引入jwt
-
依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- JDK8以上需要加入以下依赖 -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
-
jwt工具
package com.moon.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
/**
* jwt工具
* @author:Y.0
* @date:2023/11/21
*/
public class JwtUtil {
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥
* @param ttlMillis jwt过期时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);
return builder.compact();
}
/**
* Token解密
*
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
}
5.4.3 鉴权
访问其他接口的时候携带token,在接口上添加权限校验【基于方法的权限校验】。我们就需要在请求 时获取请求头的token字段,我们需要自定义过滤器,并且将过滤器添加到SpringSecurity的过滤器链 中。【使用了责任链模式】
-
自定义过滤器
package com.moon.filter;
import com.moon.domain.entity.SysUser;
import com.moon.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.SignatureException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
/**
* 捕获请求中的请求头,获取token字段,判断是否可以获取用户信息
* 我们可以继承OncePerRequestFilter 抽象类
*
* 1、获取到用户信息之后,需要将用户的信息告知SpringSecurity,SpringSecurity会去判断你访问的接口是否有相应的权限,在SpringSecurityConfig中引用就可以
* 2、告知SpringSecurity 就是使用Authentication告知框架,SpringSecurity会将信息存储到SecurityContext中然后会放到--->SecurityContextHolder中
*
* 登录的时候,放置的数据时用户名和密码,是要用来从数据库中查找用户的
* 后边的请求,判断权限的时候,放置进去的数据是用户的信息,密码就不需要了,还有用户的权限,在后面加上doFilter过滤器
*
* jwt过滤器 -- OncePerRequestFilter请求执行一次该过滤器就会执行一次
* @author:Y.0
* @date:2023/11/21
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private JwtUtils jwtUtils;
/**
* 该方法会被doFilter调用
*/
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("Authorization");
//login请求就没token,直接放行
if (token == null){
doFilter(request,response,filterChain);
return;
}
//有token,通过jwt工具类,解析用户信息
Claims claims = null;
try {
claims = jwtUtils.parseToken(token);
} catch (SignatureException e) {
throw new RuntimeException(e);
}
log.info("解析出的token信息--->{}",claims);
//获取到了数据,将数据取出,放到SysUser中
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
String avatar = claims.get("avatar", String.class);
List<String> perms = claims.get("perms", ArrayList.class);
//将用户信息放到SysUser中
SysUser sysUser = SysUser.builder()
.id(id)
.avatar(avatar)
.userName(username)
.perms(perms)
.build();
log.info("将解析出的数据封装到用户信息实体类中--->{}",sysUser);
//将用户信息数据放到SecurityContext中
//需要将用户信息数据和权限信息封装到Authentication中,然后才放到SecurityContextHolder
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(sysUser,null,sysUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
//放行
doFilter(request,response,filterChain);
}
}
!!!jwt解析数据时,集合类型,会转换为ArrayList
-
下一步我们要在SpringSecurityConfig中加入我们自定义的JwtAuthenticationFilter过滤器
public class SecurityConfig {
private SysUserDetailsService userDetailsService;
private JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable());
//放行登录请求地址
http.authorizeHttpRequests(auth -> auth.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated());
//将JwtAuthenticationFilter过滤器添加到SpringSecurity过滤器链中
//将过滤器添加到UsernamePasswordAuthenticationFilter之前,在这个过滤器中会有一个认证字段,就算登录了,也不会执行
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* AuthenticationManager:负责认证的
* DaoAuthenticationProvider:负责将userDetailsService,PasswordEncoder融合进去
*
* @param passwordEncoder
* @return
*/
public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder){
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
//关联加密编码器
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
//将daoAuthenticationProvider放进ProviderManager中,包含进去
ProviderManager providerManager = new ProviderManager(daoAuthenticationProvider);
return providerManager;
}
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
-
jwt有一个弊端:就是不能强制过期,它可以设置过期时间,但是不能强制过期,不能强迫用户下线,但是我们可以将token信息存放在redis中的形式就可以实现,只需要删除redis中的对应用户信息的token就能实现强迫用户下线。
暂无评论内容