在现代 Java 后端项目中,HTTP 接口作为同步通信的核心载体,WebSocket 作为实时双向通信的关键技术,两者共存已成常态。无论是电商平台的实时订单推送、社交应用的即时聊天,还是物联网系统的设备数据同步,都离不开这两种通信方式的配合。
但权限控制作为系统安全的第一道防线,在混合通信场景下却容易出现 "断层":HTTP 接口的权限校验可以依赖成熟的 Spring Security 拦截器,而 WebSocket 的长连接特性让传统的请求级校验机制失效;若分别设计两套权限体系,不仅开发维护成本高,还可能导致权限规则不一致、安全漏洞等问题。
本文将从底层逻辑出发,结合 RBAC 权限模型,搭建一套 "认证统一、授权统一、鉴权灵活" 的权限控制架构,同时覆盖 HTTP 接口和 WebSocket 通信的全场景。所有示例基于 JDK 17、Spring Boot 3.2.5 实现,代码严格遵循《阿里巴巴 Java 开发手册(嵩山版)》,可直接复制到项目中运行。
一、核心挑战:HTTP 与 WebSocket 的权限控制差异
要做好混合场景的权限控制,首先要明确两者的本质差异 ------ 通信模式的不同直接决定了权限校验的时机、方式和难点。
1.1 核心差异对比
| 特性 | HTTP 接口 | WebSocket |
|---|---|---|
| 通信模式 | 短连接、请求 - 响应模式 | 长连接、双向全双工通信 |
| 连接生命周期 | 单次请求建立,响应后断开 | 一次握手建立,持续保持到主动关闭 |
| 权限校验时机 | 每次请求均可独立校验 | 仅握手阶段可拦截,后续消息无天然拦截点 |
| 身份传递方式 | 请求头(Authorization)、Cookie 等 | 握手请求头、URL 参数、Token 协商 |
| 状态维护 | 无状态(依赖 Cookie/Token 维持身份) | 有状态(连接建立后保持会话) |
| 鉴权核心难点 | 批量接口的权限规则统一管理 | 连接建立后的消息级权限校验、Token 过期处理 |
1.2 权限控制流程差异
HTTP 接口权限控制流程
WebSocket 权限控制流程

从流程可以看出:HTTP 的权限控制是 "请求级" 的,每一次请求都要经过完整校验;而 WebSocket 是 "连接级 + 消息级" 的双重校验 ------ 连接建立时的认证决定是否允许接入,消息传输时的鉴权决定是否允许操作。
二、基础架构设计:统一权限控制体系
要解决两套通信方式的权限协同问题,核心是搭建 "统一认证、统一授权、分场景鉴权" 的架构。整体设计遵循以下原则:
- 认证统一:HTTP 和 WebSocket 共用一套身份认证机制(基于 JWT),避免重复开发;
- 授权统一:基于 RBAC 模型(用户 - 角色 - 权限),权限规则集中管理,支持接口级、功能级权限控制;
- 鉴权灵活:HTTP 采用 "拦截器 + 注解" 鉴权,WebSocket 采用 "握手拦截 + 消息拦截" 鉴权,适配各自通信特性;
- 状态可控:长连接的用户身份、权限信息与连接绑定,支持动态权限刷新、连接销毁时的资源释放。
2.1 整体架构图
2.2 核心技术选型
| 技术组件 | 版本号 | 作用 |
|---|---|---|
| JDK | 17 | 基础开发环境 |
| Spring Boot | 3.2.5 | 项目基础框架 |
| Spring Security | 6.2.4 | 安全框架,提供 HTTP 权限控制支持 |
| Spring WebSocket | 6.2.4 | WebSocket 通信支持 |
| MyBatis-Plus | 3.5.5 | 持久层框架,简化数据库操作 |
| MySQL | 8.0.36 | 权限数据存储数据库 |
| Redis | 7.2.4 | 权限缓存、Token 黑名单存储 |
| JWT(io.jsonwebtoken) | 0.11.5 | 无状态身份认证 |
| Lombok | 1.18.30 | 简化 Java 代码编写 |
| FastJSON2 | 2.0.49 | JSON 序列化 / 反序列化 |
| Swagger3(SpringDoc) | 2.3.0 | 接口文档自动生成 |
| Guava | 33.2.0 | 集合工具类支持 |
三、基础环境搭建:数据库与依赖配置
3.1 数据库设计(RBAC 模型)
基于 RBAC(Role-Based Access Control)角色基础访问控制模型,设计 5 张核心表,覆盖用户、角色、权限的全关联关系。
3.1.1 数据库表 SQL(MySQL 8.0)
-- 用户表
CREATE TABLE `sys_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '加密后的密码(BCrypt)',
`nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-正常',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';
-- 角色表
CREATE TABLE `sys_role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
`role_code` varchar(50) NOT NULL COMMENT '角色编码',
`remark` varchar(200) DEFAULT NULL COMMENT '角色描述',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_code` (`role_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';
-- 权限表(资源表)
CREATE TABLE `sys_permission` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '权限ID',
`perm_name` varchar(50) NOT NULL COMMENT '权限名称',
`perm_code` varchar(100) NOT NULL COMMENT '权限编码(如:sys:user:list)',
`resource_type` varchar(20) NOT NULL COMMENT '资源类型:HTTP-HTTP接口,WS-WebSocket消息',
`resource_path` varchar(200) NOT NULL COMMENT '资源路径(HTTP接口URL/WS消息类型)',
`remark` varchar(200) DEFAULT NULL COMMENT '权限描述',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_perm_code` (`perm_code`),
KEY `idx_resource_type` (`resource_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统权限表';
-- 用户-角色关联表
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`),
UNIQUE KEY `uk_user_role` (`user_id`,`role_id`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-角色关联表';
-- 角色-权限关联表
CREATE TABLE `sys_role_permission` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '关联ID',
`role_id` bigint NOT NULL COMMENT '角色ID',
`perm_id` bigint NOT NULL COMMENT '权限ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_perm` (`role_id`,`perm_id`),
KEY `idx_perm_id` (`perm_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-权限关联表';
-- 初始化测试数据
INSERT INTO `sys_user` (`username`, `password`, `nickname`) VALUES
('admin', '$2a$10$eCwW6fN9x7w9G4Z7y8V6u5t4s3r2q1p0o9n8m7l6k5j4i3h2g1f', '系统管理员'),
('test', '$2a$10$eCwW6fN9x7w9G4Z7y8V6u5t4s3r2q1p0o9n8m7l6k5j4i3h2g1f', '测试用户');
INSERT INTO `sys_role` (`role_name`, `role_code`, `remark`) VALUES
('超级管理员', 'ADMIN', '拥有所有权限'),
('普通用户', 'USER', '仅拥有基础权限');
INSERT INTO `sys_permission` (`perm_name`, `perm_code`, `resource_type`, `resource_path`, `remark`) VALUES
('查询用户列表', 'sys:user:list', 'HTTP', '/api/v1/users', 'HTTP接口-查询用户列表'),
('发送WebSocket消息', 'sys:ws:send', 'WS', 'SEND_MSG', 'WebSocket消息-发送消息'),
('接收WebSocket通知', 'sys:ws:receive', 'WS', 'RECEIVE_NOTIFY', 'WebSocket消息-接收通知');
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES
(1, 1), -- admin关联ADMIN角色
(2, 2); -- test关联USER角色
INSERT INTO `sys_role_permission` (`role_id`, `perm_id`) VALUES
(1, 1), (1, 2), (1, 3), -- ADMIN角色拥有所有权限
(2, 3); -- USER角色仅拥有接收通知权限
3.1.2 表设计说明
- 权限表(sys_permission)通过
resource_type区分 HTTP 和 WebSocket 资源,resource_path对应具体的接口 URL 或消息类型; - 密码采用 BCrypt 加密(Spring Security 默认加密方式),测试数据的密码统一为
123456; - 所有关联表都有唯一索引,避免重复关联;
- 索引设计优化查询效率,尤其是权限校验时的高频查询。
3.2 Maven 依赖配置(pom.xml)
<?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>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.ken</groupId>
<artifactId>http-ws-auth-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>http-ws-auth-demo</name>
<description>HTTP+WebSocket统一权限控制示例</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<jwt.version>0.11.5</jwt.version>
<fastjson2.version>2.0.49</fastjson2.version>
<springdoc.version>2.3.0</springdoc.version>
<guava.version>33.2.0</guava.version>
</properties>
<dependencies>
<!-- Spring Boot核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 数据库相关 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<!-- 接口文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>
3.3 核心配置文件(application.yml)
spring:
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/auth_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# Redis配置
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
# MyBatis-Plus配置
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.ken.auth.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
# JWT配置
jwt:
secret: 78a8854d-3b7e-455a-89a9-7c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d
expiration: 3600000 # Token有效期1小时(毫秒)
refresh-expiration: 86400000 # 刷新Token有效期24小时
header: Authorization
prefix: Bearer
# 服务器配置
server:
port: 8080
servlet:
context-path: /
# SpringDoc(Swagger3)配置
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operationsSorter: method
packages-to-scan: com.ken.auth.controller
四、核心组件开发:统一认证与授权
4.1 实体类设计
4.1.1 用户实体(SysUser.java)
package com.ken.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统用户实体
* @author ken
*/
@Data
@TableName("sys_user")
public class SysUser {
/**
* 用户ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码(BCrypt加密)
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 状态:0-禁用,1-正常
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
4.1.2 角色实体(SysRole.java)
package com.ken.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统角色实体
* @author ken
*/
@Data
@TableName("sys_role")
public class SysRole {
/**
* 角色ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 角色名称
*/
private String roleName;
/**
* 角色编码
*/
private String roleCode;
/**
* 角色描述
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
4.1.3 权限实体(SysPermission.java)
package com.ken.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 系统权限实体
* @author ken
*/
@Data
@TableName("sys_permission")
public class SysPermission {
/**
* 权限ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 权限名称
*/
private String permName;
/**
* 权限编码
*/
private String permCode;
/**
* 资源类型:HTTP-HTTP接口,WS-WebSocket消息
*/
private String resourceType;
/**
* 资源路径(HTTP接口URL/WS消息类型)
*/
private String resourcePath;
/**
* 权限描述
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
4.1.4 用户角色关联实体(SysUserRole.java)
package com.ken.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 用户-角色关联实体
* @author ken
*/
@Data
@TableName("sys_user_role")
public class SysUserRole {
/**
* 关联ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 角色ID
*/
private Long roleId;
}
4.1.5 角色权限关联实体(SysRolePermission.java)
package com.ken.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 角色-权限关联实体
* @author ken
*/
@Data
@TableName("sys_role_permission")
public class SysRolePermission {
/**
* 关联ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 角色ID
*/
private Long roleId;
/**
* 权限ID
*/
private Long permId;
}
4.1.6 自定义用户详情(LoginUser.java)
用于 Spring Security 和 JWT 中存储用户身份与权限信息:
package com.ken.auth.entity;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Set;
/**
* 登录用户详情
* @author ken
*/
@Data
public class LoginUser implements UserDetails {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 角色编码集合
*/
private Set<String> roleCodes;
/**
* 权限编码集合
*/
private Set<String> permCodes;
/**
* 账户状态:true-正常,false-禁用
*/
private boolean enabled;
/**
* Spring Security权限集合
*/
private Collection<? extends GrantedAuthority> authorities;
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
}
4.2 JWT 工具类(JwtUtils.java)
提供 Token 生成、校验、解析等核心功能,是统一认证的基础:
package com.ken.auth.utils;
import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson2.JSON;
import com.ken.auth.entity.LoginUser;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.util.Collection;
import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;
/**
* JWT工具类
* @author ken
*/
@Slf4j
@Component
public class JwtUtils {
/**
* JWT密钥(至少32位)
*/
@Value("${jwt.secret}")
private String secret;
/**
* Token有效期(毫秒)
*/
@Value("${jwt.expiration}")
private long expiration;
/**
* Token请求头
*/
@Value("${jwt.header}")
private String header;
/**
* Token前缀
*/
@Value("${jwt.prefix}")
private String prefix;
/**
* 生成Token
* @param authentication 认证信息
* @return JWT Token
*/
public String generateToken(Authentication authentication) {
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
// 构建JWT Token
return Jwts.builder()
// 存入用户ID
.claim("userId", loginUser.getUserId())
// 存入用户名
.claim("username", loginUser.getUsername())
// 存入昵称
.claim("nickname", loginUser.getNickname())
// 存入角色编码
.claim("roleCodes", JSON.toJSONString(loginUser.getRoleCodes()))
// 存入权限编码
.claim("permCodes", JSON.toJSONString(loginUser.getPermCodes()))
// 签发时间
.setIssuedAt(new Date())
// 过期时间
.setExpiration(new Date(System.currentTimeMillis() + expiration))
// 签名算法
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 从Token中获取用户信息
* @param token JWT Token
* @return 登录用户详情
*/
public LoginUser getLoginUser(String token) {
Claims claims = parseClaims(token);
LoginUser loginUser = new LoginUser();
// 解析用户ID
loginUser.setUserId(claims.get("userId", Long.class));
// 解析用户名
loginUser.setUsername(claims.get("username", String.class));
// 解析昵称
loginUser.setNickname(claims.get("nickname", String.class));
// 解析角色编码(JSON字符串转Set)
String roleCodesJson = claims.get("roleCodes", String.class);
Set<String> roleCodes = JSON.parseObject(roleCodesJson, Set.class);
loginUser.setRoleCodes(roleCodes);
// 解析权限编码(JSON字符串转Set)
String permCodesJson = claims.get("permCodes", String.class);
Set<String> permCodes = JSON.parseObject(permCodesJson, Set.class);
loginUser.setPermCodes(permCodes);
// 设置权限(Spring Security需要)
Collection<GrantedAuthority> authorities = permCodes.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
loginUser.setAuthorities(authorities);
// 设置账户状态为正常
loginUser.setEnabled(true);
return loginUser;
}
/**
* 验证Token有效性
* @param token JWT Token
* @return true-有效,false-无效
*/
public boolean validateToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (MalformedJwtException e) {
log.error("无效的JWT Token:{}", e.getMessage());
} catch (ExpiredJwtException e) {
log.error("JWT Token已过期:{}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.error("不支持的JWT Token:{}", e.getMessage());
} catch (IllegalArgumentException e) {
log.error("JWT Token为空:{}", e.getMessage());
} catch (Exception e) {
log.error("JWT Token验证失败:{}", e.getMessage());
}
return false;
}
/**
* 解析Token中的Claims
* @param token JWT Token
* @return Claims对象
*/
private Claims parseClaims(String token) {
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
/**
* 从请求头中提取Token
* @param authHeader 请求头中的Authorization值
* @return 纯Token字符串(去除前缀)
*/
public String extractToken(String authHeader) {
if (StringUtils.hasText(authHeader) && authHeader.startsWith(prefix)) {
return authHeader.substring(prefix.length() + 1);
}
return null;
}
/**
* 获取Token剩余有效期(秒)
* @param token JWT Token
* @return 剩余秒数,过期返回0
*/
public long getRemainingTime(String token) {
try {
Claims claims = parseClaims(token);
Date expiration = claims.getExpiration();
long remaining = expiration.getTime() - System.currentTimeMillis();
return Math.max(0, remaining / 1000);
} catch (Exception e) {
return 0;
}
}
}
4.3 数据访问层(Mapper)
基于 MyBatis-Plus 实现,简化数据库操作:
4.3.1 SysUserMapper.java
package com.ken.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ken.auth.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Set;
/**
* 用户Mapper
* @author ken
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 根据用户名查询用户
* @param username 用户名
* @return 用户实体
*/
SysUser selectByUsername(@Param("username") String username);
/**
* 根据用户ID查询角色编码
* @param userId 用户ID
* @return 角色编码集合
*/
Set<String> selectRoleCodesByUserId(@Param("userId") Long userId);
/**
* 根据用户ID查询权限编码
* @param userId 用户ID
* @return 权限编码集合
*/
Set<String> selectPermCodesByUserId(@Param("userId") Long userId);
}
4.3.2 SysUserMapper.xml
<?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.ken.auth.mapper.SysUserMapper">
<select id="selectByUsername" resultType="com.ken.auth.entity.SysUser">
SELECT id, username, password, nickname, status, create_time, update_time
FROM sys_user
WHERE username = #{username}
</select>
<select id="selectRoleCodesByUserId" resultType="java.lang.String">
SELECT sr.role_code
FROM sys_role sr
JOIN sys_user_role sur ON sr.id = sur.role_id
WHERE sur.user_id = #{userId}
</select>
<select id="selectPermCodesByUserId" resultType="java.lang.String">
SELECT sp.perm_code
FROM sys_permission sp
JOIN sys_role_permission srp ON sp.id = srp.perm_id
JOIN sys_user_role sur ON srp.role_id = sur.role_id
WHERE sur.user_id = #{userId}
</select>
</mapper>
其他 Mapper(SysRoleMapper、SysPermissionMapper 等)仅需继承 BaseMapper,无需额外方法,MyBatis-Plus 自动提供 CRUD 功能:
package com.ken.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ken.auth.entity.SysRole;
import org.apache.ibatis.annotations.Mapper;
/**
* 角色Mapper
* @author ken
*/
@Mapper
public interface SysRoleMapper extends BaseMapper<SysRole> {
}
4.4 服务层实现
4.4.1 用户服务(SysUserService.java)
package com.ken.auth.service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.common.collect.Sets;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.entity.SysUser;
import com.ken.auth.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import java.util.Set;
/**
* 用户服务实现
* @author ken
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> implements UserDetailsService {
private final SysUserMapper sysUserMapper;
/**
* 根据用户名查询用户详情(Spring Security认证用)
* @param username 用户名
* @return 用户详情
* @throws UsernameNotFoundException 用户名不存在异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
SysUser sysUser = sysUserMapper.selectByUsername(username);
if (ObjectUtils.isEmpty(sysUser)) {
log.error("用户名[{}]不存在", username);
throw new UsernameNotFoundException("用户名或密码错误");
}
// 校验用户状态
if (sysUser.getStatus() != 1) {
log.error("用户[{}]已被禁用", username);
throw new UsernameNotFoundException("用户已被禁用");
}
// 查询用户角色编码
Set<String> roleCodes = sysUserMapper.selectRoleCodesByUserId(sysUser.getId());
if (ObjectUtils.isEmpty(roleCodes)) {
roleCodes = Sets.newHashSet();
}
// 查询用户权限编码
Set<String> permCodes = sysUserMapper.selectPermCodesByUserId(sysUser.getId());
if (ObjectUtils.isEmpty(permCodes)) {
permCodes = Sets.newHashSet();
}
// 构建LoginUser对象
LoginUser loginUser = new LoginUser();
loginUser.setUserId(sysUser.getId());
loginUser.setUsername(sysUser.getUsername());
loginUser.setPassword(sysUser.getPassword());
loginUser.setNickname(sysUser.getNickname());
loginUser.setRoleCodes(roleCodes);
loginUser.setPermCodes(permCodes);
loginUser.setEnabled(true);
return loginUser;
}
/**
* 根据用户ID查询用户权限编码
* @param userId 用户ID
* @return 权限编码集合
*/
public Set<String> getUserPermCodes(Long userId) {
return sysUserMapper.selectPermCodesByUserId(userId);
}
}
4.4.2 权限校验服务(PermissionService.java)
核心服务,提供统一的权限校验逻辑,供 HTTP 和 WebSocket 调用:
package com.ken.auth.service;
import com.google.common.collect.Sets;
import com.ken.auth.entity.SysPermission;
import com.ken.auth.mapper.SysPermissionMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import java.util.Set;
/**
* 权限校验服务
* @author ken
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PermissionService {
private final SysPermissionMapper sysPermissionMapper;
private final SysUserService sysUserService;
/**
* 校验用户是否拥有指定权限
* @param userId 用户ID
* @param permCode 权限编码
* @return true-有权限,false-无权限
*/
public boolean hasPermission(Long userId, String permCode) {
if (ObjectUtils.isEmpty(userId) || permCode == null) {
return false;
}
// 查询用户拥有的权限
Set<String> userPermCodes = sysUserService.getUserPermCodes(userId);
if (CollectionUtils.isEmpty(userPermCodes)) {
return false;
}
// 超级管理员拥有所有权限(角色编码为ADMIN)
Set<String> roleCodes = sysUserService.getById(userId).getRoleCodes();
if (roleCodes.contains("ADMIN")) {
return true;
}
// 校验权限
return userPermCodes.contains(permCode);
}
/**
* 根据资源类型和路径获取权限编码
* @param resourceType 资源类型(HTTP/WS)
* @param resourcePath 资源路径(URL/消息类型)
* @return 权限编码,无匹配返回null
*/
@Cacheable(value = "permCache", key = "#resourceType + ':' + #resourcePath")
public String getPermCodeByResource(String resourceType, String resourcePath) {
if (ObjectUtils.isEmpty(resourceType) || ObjectUtils.isEmpty(resourcePath)) {
return null;
}
// 构造查询条件
SysPermission query = new SysPermission();
query.setResourceType(resourceType);
query.setResourcePath(resourcePath);
// 查询权限(MyBatis-Plus条件查询)
SysPermission permission = sysPermissionMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>(query)
);
return ObjectUtils.isEmpty(permission) ? null : permission.getPermCode();
}
/**
* 校验HTTP接口权限
* @param userId 用户ID
* @param requestUrl 请求URL
* @return true-有权限,false-无权限
*/
public boolean checkHttpPermission(Long userId, String requestUrl) {
// 获取接口对应的权限编码
String permCode = getPermCodeByResource("HTTP", requestUrl);
if (permCode == null) {
// 无对应权限配置,默认允许访问(可根据业务调整为拒绝)
return true;
}
// 校验权限
return hasPermission(userId, permCode);
}
/**
* 校验WebSocket消息权限
* @param userId 用户ID
* @param msgType 消息类型
* @return true-有权限,false-无权限
*/
public boolean checkWsPermission(Long userId, String msgType) {
// 获取消息对应的权限编码
String permCode = getPermCodeByResource("WS", msgType);
if (permCode == null) {
// 无对应权限配置,默认拒绝访问(WebSocket消息建议严格控制)
return false;
}
// 校验权限
return hasPermission(userId, permCode);
}
}
4.5 Spring Security 配置(SecurityConfig.java)
配置 HTTP 接口的认证逻辑,禁用默认表单登录,启用 JWT 认证:
package com.ken.auth.config;
import com.ken.auth.filter.JwtAuthenticationFilter;
import com.ken.auth.handler.AccessDeniedHandlerImpl;
import com.ken.auth.handler.AuthenticationEntryPointImpl;
import com.ken.auth.service.SysUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.config.http.SessionCreationPolicy;
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;
/**
* Spring Security配置
* @author ken
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // 启用方法级权限注解
@RequiredArgsConstructor
public class SecurityConfig {
private final SysUserService sysUserService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuthenticationEntryPointImpl authenticationEntryPoint;
private final AccessDeniedHandlerImpl accessDeniedHandler;
/**
* 密码加密器
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 认证提供者
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
// 设置用户详情服务
provider.setUserDetailsService(sysUserService);
// 设置密码加密器
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
/**
* 认证管理器
* @param config 认证配置
* @return AuthenticationManager
* @throws Exception 异常
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
/**
* 安全过滤器链
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF(前后端分离项目无需CSRF保护)
.csrf(csrf -> csrf.disable())
// 禁用会话(JWT无状态认证)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置请求授权规则
.authorizeHttpRequests(auth -> auth
// 放行登录接口
.requestMatchers("/api/v1/auth/login").permitAll()
// 放行Swagger3接口
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/api-docs/**").permitAll()
// 其他所有请求需要认证
.anyRequest().authenticated()
)
// 配置异常处理器
.exceptionHandling(ex -> ex
// 未认证异常处理器
.authenticationEntryPoint(authenticationEntryPoint)
// 未授权异常处理器
.accessDeniedHandler(accessDeniedHandler)
)
// 添加JWT认证过滤器(在用户名密码认证过滤器之前)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// 设置认证提供者
http.authenticationProvider(authenticationProvider());
return http.build();
}
}
4.6 异常处理器
4.6.1 未认证异常处理器(AuthenticationEntryPointImpl.java)
package com.ken.auth.handler;
import com.alibaba.fastjson2.JSON;
import com.ken.auth.common.Result;
import com.ken.auth.common.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 未认证异常处理器(401)
* @author ken
*/
@Slf4j
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
log.error("未认证:{}", authException.getMessage());
// 设置响应头
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 构建响应结果
Result<?> result = Result.error(ResultCode.UNAUTHORIZED, "用户未登录或Token失效");
// 输出响应
PrintWriter out = response.getWriter();
out.write(JSON.toJSONString(result));
out.flush();
out.close();
}
}
4.6.2 未授权异常处理器(AccessDeniedHandlerImpl.java)
package com.ken.auth.handler;
import com.alibaba.fastjson2.JSON;
import com.ken.auth.common.Result;
import com.ken.auth.common.ResultCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 未授权异常处理器(403)
* @author ken
*/
@Slf4j
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
log.error("未授权:{}", accessDeniedException.getMessage());
// 设置响应头
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 构建响应结果
Result<?> result = Result.error(ResultCode.FORBIDDEN, "无权限访问");
// 输出响应
PrintWriter out = response.getWriter();
out.write(JSON.toJSONString(result));
out.flush();
out.close();
}
}
4.7 通用结果类
4.7.1 结果码枚举(ResultCode.java)
package com.ken.auth.common;
/**
* 结果码枚举
* @author ken
*/
public enum ResultCode {
SUCCESS(200, "操作成功"),
ERROR(500, "操作失败"),
UNAUTHORIZED(401, "未认证"),
FORBIDDEN(403, "未授权"),
NOT_FOUND(404, "资源不存在");
private final int code;
private final String msg;
ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
4.7.2 响应结果类(Result.java)
package com.ken.auth.common;
import lombok.Data;
import java.io.Serializable;
/**
* 统一响应结果
* @author ken
*/
@Data
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 响应码
*/
private int code;
/**
* 响应信息
*/
private String msg;
/**
* 响应数据
*/
private T data;
/**
* 成功响应(无数据)
* @param <T> 数据类型
* @return Result<T>
*/
public static <T> Result<T> success() {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), null);
}
/**
* 成功响应(带数据)
* @param data 响应数据
* @param <T> 数据类型
* @return Result<T>
*/
public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), data);
}
/**
* 错误响应
* @param code 响应码
* @param msg 响应信息
* @param <T> 数据类型
* @return Result<T>
*/
public static <T> Result<T> error(int code, String msg) {
return new Result<>(code, msg, null);
}
/**
* 错误响应(基于ResultCode)
* @param resultCode 结果码枚举
* @param <T> 数据类型
* @return Result<T>
*/
public static <T> Result<T> error(ResultCode resultCode) {
return new Result<>(resultCode.getCode(), resultCode.getMsg(), null);
}
/**
* 错误响应(基于ResultCode,自定义消息)
* @param resultCode 结果码枚举
* @param msg 自定义消息
* @param <T> 数据类型
* @return Result<T>
*/
public static <T> Result<T> error(ResultCode resultCode, String msg) {
return new Result<>(resultCode.getCode(), msg, null);
}
}
五、HTTP 接口权限控制实现
HTTP 接口的权限控制采用 "拦截器认证 + 注解鉴权" 的双重机制:拦截器负责验证 Token 有效性、解析用户身份;注解(@PreAuthorize)负责细粒度的权限校验。
5.1 JWT 认证拦截器(JwtAuthenticationFilter.java)
package com.ken.auth.filter;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.utils.JwtUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT认证拦截器
* 每次HTTP请求都会经过该拦截器,验证Token并设置认证信息
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
// 从请求头中获取Token
String authHeader = request.getHeader(jwtUtils.getHeader());
String token = jwtUtils.extractToken(authHeader);
// Token不为空且未认证
if (StringUtils.hasText(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
// 验证Token有效性
if (jwtUtils.validateToken(token)) {
// 解析用户信息
LoginUser loginUser = jwtUtils.getLoginUser(token);
// 构建认证Token
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginUser, null, loginUser.getAuthorities()
);
// 设置请求详情
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 将认证信息存入SecurityContext
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
log.info("用户[{}]认证通过,请求URL:{}", loginUser.getUsername(), request.getRequestURI());
}
}
} catch (Exception e) {
log.error("JWT认证失败:{}", e.getMessage());
}
// 继续执行过滤链
filterChain.doFilter(request, response);
}
}
5.2 接口权限注解使用示例
5.2.1 认证控制器(AuthController.java)
提供登录接口,生成 JWT Token:
package com.ken.auth.controller;
import com.ken.auth.common.Result;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.utils.JwtUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
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 org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
/**
* 认证控制器
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "认证接口", description = "用户登录、Token刷新等接口")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtils jwtUtils;
/**
* 登录请求参数
*/
@lombok.Data
public static class LoginRequest {
private String username;
private String password;
}
/**
* 登录响应结果
*/
@lombok.Data
public static class LoginResponse {
private String token;
private Long userId;
private String username;
private String nickname;
}
/**
* 用户登录
* @param loginRequest 登录参数(用户名、密码)
* @return 登录结果(包含Token)
*/
@PostMapping("/login")
@Operation(summary = "用户登录", description = "输入用户名和密码,获取JWT Token")
public Result<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
// 校验参数
if (!StringUtils.hasText(loginRequest.getUsername())) {
return Result.error(400, "用户名不能为空");
}
if (!StringUtils.hasText(loginRequest.getPassword())) {
return Result.error(400, "密码不能为空");
}
try {
// 构建认证Token
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(), loginRequest.getPassword()
);
// 执行认证(调用SysUserService.loadUserByUsername)
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 将认证信息存入SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 生成JWT Token
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
String token = jwtUtils.generateToken(authentication);
// 构建响应结果
LoginResponse loginResponse = new LoginResponse();
loginResponse.setToken(token);
loginResponse.setUserId(loginUser.getUserId());
loginResponse.setUsername(loginUser.getUsername());
loginResponse.setNickname(loginUser.getNickname());
log.info("用户[{}]登录成功", loginUser.getUsername());
return Result.success(loginResponse);
} catch (Exception e) {
log.error("用户[{}]登录失败:{}", loginRequest.getUsername(), e.getMessage());
return Result.error(401, "用户名或密码错误");
}
}
}
5.2.2 用户控制器(UserController.java)
使用@PreAuthorize注解实现接口级权限控制:
package com.ken.auth.controller;
import com.ken.auth.common.Result;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.entity.SysUser;
import com.ken.auth.service.SysUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 用户控制器
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Tag(name = "用户接口", description = "用户查询、修改等接口,包含权限控制示例")
public class UserController {
private final SysUserService sysUserService;
/**
* 查询用户列表(需要sys:user:list权限)
* @return 用户列表
*/
@GetMapping
@Operation(summary = "查询用户列表", description = "需要sys:user:list权限才能访问")
@PreAuthorize("hasPermission('', 'sys:user:list')") // 权限注解,校验sys:user:list权限
public Result<List<SysUser>> listUsers() {
// 获取当前登录用户信息
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
log.info("用户[{}]查询用户列表", loginUser.getUsername());
// 查询所有用户(实际业务中应添加分页)
List<SysUser> userList = sysUserService.list();
return Result.success(userList);
}
/**
* 获取当前登录用户信息(无需特定权限)
* @return 当前用户信息
*/
@GetMapping("/current")
@Operation(summary = "获取当前登录用户信息", description = "所有已登录用户均可访问")
public Result<LoginUser> getCurrentUser() {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
log.info("用户[{}]查询当前登录信息", loginUser.getUsername());
return Result.success(loginUser);
}
}
5.3 权限注解说明
@PreAuthorize("hasPermission('', 'sys:user:list')"):方法执行前校验权限,hasPermission是自定义的权限表达式(需配置);- 若需要角色校验,可使用
@PreAuthorize("hasRole('ADMIN')")或@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')"); - 权限表达式支持复杂逻辑,如
@PreAuthorize("hasPermission('', 'sys:user:list') and hasRole('ADMIN')")。
5.4 自定义权限表达式配置(PermissionConfig.java)
package com.ken.auth.config;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.service.PermissionService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* 权限表达式配置
* 自定义hasPermission表达式,支持权限校验
* @author ken
*/
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class PermissionConfig {
private final PermissionService permissionService;
/**
* 自定义方法安全表达式处理器
* @return MethodSecurityExpressionHandler
*/
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler() {
@Override
protected PermissionEvaluationContext createEvaluationContextInternal(Authentication auth, Object target) {
PermissionEvaluationContext context = new PermissionEvaluationContext(auth, target);
// 注入权限服务
context.setPermissionService(permissionService);
return context;
}
};
return handler;
}
/**
* 自定义权限评估上下文
*/
public static class PermissionEvaluationContext extends org.springframework.security.access.expression.method.MethodSecurityEvaluationContext {
private PermissionService permissionService;
public PermissionEvaluationContext(Authentication authentication, Object target) {
super(authentication, target);
}
public void setPermissionService(PermissionService permissionService) {
this.permissionService = permissionService;
}
/**
* 自定义hasPermission方法,供@PreAuthorize注解使用
* @param target 目标资源(可空)
* @param permCode 权限编码
* @return true-有权限,false-无权限
*/
public boolean hasPermission(Object target, String permCode) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof LoginUser loginUser)) {
return false;
}
// 调用权限服务校验权限
return permissionService.hasPermission(loginUser.getUserId(), permCode);
}
}
}
六、WebSocket 权限控制实现
WebSocket 的权限控制需解决两个核心问题:连接建立时的身份认证 和消息交互时的权限校验。由于 WebSocket 是长连接,传统的请求级拦截机制不再适用,需通过握手拦截器和消息拦截器实现全流程控制。
6.1 WebSocket 核心配置(WebSocketConfig.java)
package com.ken.auth.config;
import com.ken.auth.interceptor.WsHandshakeInterceptor;
import com.ken.auth.interceptor.WsMessageInterceptor;
import com.ken.auth.handler.WsMessageHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* WebSocket 配置类
* 注册 WebSocket 处理器和拦截器,配置连接路径
* @author ken
*/
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final WsMessageHandler wsMessageHandler;
private final WsHandshakeInterceptor wsHandshakeInterceptor;
private final WsMessageInterceptor wsMessageInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 注册 WebSocket 端点,允许客户端通过 /ws 路径连接
registry.addHandler(wsMessageHandler, "/ws")
// 允许跨域请求(生产环境需限制具体域名)
.setAllowedOrigins("*")
// 添加握手拦截器(连接建立前的认证)
.addInterceptors(wsHandshakeInterceptor)
// 支持 SockJS 降级(兼容不支持 WebSocket 的浏览器)
.withSockJS();
// 注册消息拦截器(所有消息都会经过该拦截器)
registry.addHandler(wsMessageHandler, "/ws")
.addInterceptors(wsMessageInterceptor);
}
}
6.2 握手拦截器(WsHandshakeInterceptor.java)
握手拦截器在 WebSocket 连接建立前(HTTP 升级为 WebSocket 协议的握手阶段)进行拦截,验证用户身份并绑定到会话中。
package com.ken.auth.interceptor;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.utils.JwtUtils;
import com.ken.auth.manager.WsSessionManager;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
/**
* WebSocket 握手拦截器
* 在连接建立前验证 Token,解析用户信息并绑定到会话
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WsHandshakeInterceptor implements HandshakeInterceptor {
private final JwtUtils jwtUtils;
private final WsSessionManager wsSessionManager;
/**
* 握手前拦截(核心认证逻辑)
* @param request 握手请求
* @param response 握手响应
* @param handler WebSocket 处理器
* @param attributes 会话属性(可存储用户信息)
* @return true-允许握手,false-拒绝握手
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler handler, Map<String, Object> attributes) {
try {
// 从请求中提取 Token(支持请求头或 URL 参数)
String token = extractToken(request);
if (token == null) {
log.warn("WebSocket 握手失败:未携带 Token");
return false;
}
// 验证 Token 有效性
if (!jwtUtils.validateToken(token)) {
log.warn("WebSocket 握手失败:Token 无效或已过期");
return false;
}
// 解析用户信息
LoginUser loginUser = jwtUtils.getLoginUser(token);
if (loginUser == null) {
log.warn("WebSocket 握手失败:用户信息解析失败");
return false;
}
// 将用户信息存入会话属性(后续消息处理可获取)
attributes.put("loginUser", loginUser);
log.info("用户[{}] WebSocket 握手认证通过", loginUser.getUsername());
return true;
} catch (Exception e) {
log.error("WebSocket 握手异常:{}", e.getMessage());
return false;
}
}
/**
* 握手后处理(通常无需操作)
*/
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler handler, Exception exception) {
// 从会话属性中获取用户信息
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
Map<String, Object> attributes = servletRequest.getServletRequest().getAttribute("javax.websocket.server.ServerEndpointConfig")
.getUserProperties();
LoginUser loginUser = (LoginUser) attributes.get("loginUser");
if (loginUser != null) {
// 记录连接建立日志
log.info("用户[{}] WebSocket 连接已建立", loginUser.getUsername());
}
}
/**
* 从请求中提取 Token(优先从请求头,其次从 URL 参数)
* @param request 握手请求
* @return Token 字符串或 null
*/
private String extractToken(ServerHttpRequest request) {
// 尝试从请求头获取
String token = jwtUtils.extractToken(request.getHeaders().getFirst(jwtUtils.getHeader()));
if (token != null) {
return token;
}
// 尝试从 URL 参数获取(格式:ws://localhost:8080/ws?token=xxx)
if (request instanceof ServletServerHttpRequest servletRequest) {
return servletRequest.getServletRequest().getParameter("token");
}
return null;
}
}
6.3 WebSocket 会话管理器(WsSessionManager.java)
管理所有活跃的 WebSocket 会话,实现用户与会话的绑定,方便后续消息推送和权限验证。
package com.ken.auth.manager;
import com.ken.auth.entity.LoginUser;
import jakarta.websocket.Session;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* WebSocket 会话管理器
* 存储用户与 WebSocket 会话的映射关系,支持会话的添加、移除和查询
* @author ken
*/
@Slf4j
@Component
public class WsSessionManager {
/**
* 用户会话映射(userId -> Session)
* 使用 ConcurrentHashMap 保证线程安全
*/
private final Map<Long, Session> userSessionMap = new ConcurrentHashMap<>();
/**
* 添加会话(连接建立时调用)
* @param loginUser 用户信息
* @param session WebSocket 会话
*/
public void addSession(LoginUser loginUser, Session session) {
if (loginUser == null || session == null) {
return;
}
// 存储会话,并添加关闭监听器(会话关闭时自动移除)
userSessionMap.put(loginUser.getUserId(), session);
session.addMessageHandler(session1 -> removeSession(loginUser.getUserId()));
log.info("用户[{}]的 WebSocket 会话已添加,当前在线用户数:{}",
loginUser.getUsername(), userSessionMap.size());
}
/**
* 移除会话(连接关闭时调用)
* @param userId 用户ID
*/
public void removeSession(Long userId) {
if (userId == null) {
return;
}
Session session = userSessionMap.remove(userId);
if (session != null) {
log.info("用户[{}]的 WebSocket 会话已移除,当前在线用户数:{}",
userId, userSessionMap.size());
}
}
/**
* 根据用户ID获取会话
* @param userId 用户ID
* @return WebSocket 会话或 null
*/
public Session getSession(Long userId) {
return userId == null ? null : userSessionMap.get(userId);
}
/**
* 获取所有在线用户ID
* @return 用户ID集合
*/
public Set<Long> getOnlineUserIds() {
return userSessionMap.keySet();
}
/**
* 获取所有在线用户的会话
* @return 会话集合
*/
public Set<Session> getAllSessions() {
return Set.copyOf(userSessionMap.values());
}
/**
* 判断用户是否在线
* @param userId 用户ID
* @return true-在线,false-离线
*/
public boolean isOnline(Long userId) {
return userId != null && userSessionMap.containsKey(userId);
}
}
6.4 消息模型(WsMessage.java)
定义 WebSocket 消息的统一格式,包含消息类型、内容、发送者等核心字段。
package com.ken.auth.entity;
import com.alibaba.fastjson2.annotation.JSONField;
import lombok.Data;
import java.time.LocalDateTime;
/**
* WebSocket 消息模型
* 统一消息格式,便于序列化和解析
* @author ken
*/
@Data
public class WsMessage {
/**
* 消息ID(唯一标识)
*/
private String messageId;
/**
* 消息类型(与权限表的 resource_path 对应)
* 例如:SEND_MSG-发送消息,RECEIVE_NOTIFY-接收通知
*/
private String msgType;
/**
* 消息内容(JSON格式字符串)
*/
private String content;
/**
* 发送者用户ID
*/
@JSONField(serialize = false) // 序列化时忽略(由服务端填充)
private Long senderId;
/**
* 发送者用户名
*/
@JSONField(serialize = false)
private String senderName;
/**
* 接收者用户ID(null表示广播)
*/
private Long receiverId;
/**
* 消息发送时间
*/
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime sendTime;
}
6.5 消息拦截器(WsMessageInterceptor.java)
在消息发送和接收时进行拦截,校验用户是否有权限操作该类型的消息,是 WebSocket 权限控制的核心环节。
package com.ken.auth.interceptor;
import com.alibaba.fastjson2.JSON;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.entity.WsMessage;
import com.ken.auth.service.PermissionService;
import jakarta.websocket.Session;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
/**
* WebSocket 消息拦截器
* 拦截所有 WebSocket 消息,校验消息级权限
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WsMessageInterceptor extends TextWebSocketHandler {
private final PermissionService permissionService;
/**
* 消息接收前拦截(核心鉴权逻辑)
* @param session WebSocket 会话
* @param message 接收的消息
* @throws Exception 异常
*/
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
try {
// 从会话中获取用户信息(握手阶段已存入)
LoginUser loginUser = (LoginUser) session.getAttributes().get("loginUser");
if (loginUser == null) {
log.warn("WebSocket 消息拦截:未获取到用户信息,拒绝处理");
session.close(CloseStatus.POLICY_VIOLATION.withReason("未认证"));
return;
}
// 解析消息内容
String payload = message.getPayload();
WsMessage wsMessage = JSON.parseObject(payload, WsMessage.class);
if (wsMessage == null || !org.springframework.util.StringUtils.hasText(wsMessage.getMsgType())) {
log.warn("用户[{}]发送无效消息:{}", loginUser.getUsername(), payload);
session.sendMessage(new TextMessage(JSON.toJSONString("消息格式错误:缺少msgType")));
return;
}
// 校验消息权限(调用统一权限服务)
boolean hasPermission = permissionService.checkWsPermission(loginUser.getUserId(), wsMessage.getMsgType());
if (!hasPermission) {
log.warn("用户[{}]无权限发送消息类型[{}]", loginUser.getUsername(), wsMessage.getMsgType());
session.sendMessage(new TextMessage(JSON.toJSONString("无权限操作该类型消息")));
session.close(CloseStatus.POLICY_VIOLATION.withReason("无权限"));
return;
}
// 权限校验通过,继续处理消息(调用实际处理器)
super.handleTextMessage(session, message);
} catch (Exception e) {
log.error("WebSocket 消息拦截异常:{}", e.getMessage());
session.close(CloseStatus.SERVER_ERROR.withReason("处理消息失败"));
}
}
/**
* 连接关闭时处理
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
LoginUser loginUser = (LoginUser) session.getAttributes().get("loginUser");
if (loginUser != null) {
log.info("用户[{}]的 WebSocket 连接已关闭,原因:{}", loginUser.getUsername(), status.getReason());
}
}
}
6.6 消息处理器(WsMessageHandler.java)
负责实际的消息处理逻辑,根据消息类型分发处理,如发送私信、广播通知等。
package com.ken.auth.handler;
import com.alibaba.fastjson2.JSON;
import com.ken.auth.entity.LoginUser;
import com.ken.auth.entity.WsMessage;
import com.ken.auth.manager.WsSessionManager;
import jakarta.websocket.Session;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* WebSocket 消息处理器
* 处理实际的消息逻辑,如发送消息、广播通知等
* @author ken
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WsMessageHandler extends TextWebSocketHandler {
private final WsSessionManager wsSessionManager;
/**
* 连接建立时调用(注册会话)
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 从会话中获取用户信息
LoginUser loginUser = (LoginUser) session.getAttributes().get("loginUser");
if (loginUser != null) {
// 将会话添加到管理器
wsSessionManager.addSession(loginUser, session);
try {
// 发送连接成功消息
WsMessage welcomeMsg = new WsMessage();
welcomeMsg.setMessageId(UUID.randomUUID().toString());
welcomeMsg.setMsgType("CONNECT_SUCCESS");
welcomeMsg.setContent("WebSocket 连接成功,当前在线用户数:" + wsSessionManager.getOnlineUserIds().size());
welcomeMsg.setSendTime(LocalDateTime.now());
session.sendMessage(new TextMessage(JSON.toJSONString(welcomeMsg)));
} catch (IOException e) {
log.error("发送连接成功消息失败:{}", e.getMessage());
}
}
}
/**
* 处理接收到的消息
*/
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
LoginUser loginUser = (LoginUser) session.getAttributes().get("loginUser");
if (loginUser == null) {
return;
}
// 解析消息
WsMessage wsMessage = JSON.parseObject(message.getPayload(), WsMessage.class);
wsMessage.setSenderId(loginUser.getUserId());
wsMessage.setSenderName(loginUser.getUsername());
wsMessage.setSendTime(LocalDateTime.now());
if (wsMessage.getMessageId() == null) {
wsMessage.setMessageId(UUID.randomUUID().toString());
}
log.info("用户[{}]发送消息:{}", loginUser.getUsername(), JSON.toJSONString(wsMessage));
// 根据消息类型处理
switch (wsMessage.getMsgType()) {
case "SEND_MSG":
handleSendMessage(wsMessage);
break;
case "RECEIVE_NOTIFY":
handleReceiveNotify(wsMessage, session);
break;
default:
session.sendMessage(new TextMessage(JSON.toJSONString("不支持的消息类型:" + wsMessage.getMsgType())));
}
}
/**
* 处理发送消息(单聊)
* @param message 消息对象
*/
private void handleSendMessage(WsMessage message) throws IOException {
Long receiverId = message.getReceiverId();
if (receiverId == null) {
throw new IllegalArgumentException("接收者ID不能为空");
}
// 获取接收者会话
WebSocketSession receiverSession = (WebSocketSession) wsSessionManager.getSession(receiverId);
if (receiverSession == null || !receiverSession.isOpen()) {
// 接收者不在线,可根据业务逻辑处理(如存入离线消息表)
WebSocketSession senderSession = (WebSocketSession) wsSessionManager.getSession(message.getSenderId());
senderSession.sendMessage(new TextMessage(JSON.toJSONString("用户[ID:" + receiverId + "]不在线")));
return;
}
// 发送消息给接收者
receiverSession.sendMessage(new TextMessage(JSON.toJSONString(message)));
// 发送成功回执给发送者
WebSocketSession senderSession = (WebSocketSession) wsSessionManager.getSession(message.getSenderId());
WsMessage ackMsg = new WsMessage();
ackMsg.setMessageId(UUID.randomUUID().toString());
ackMsg.setMsgType("SEND_ACK");
ackMsg.setContent("消息已发送给用户[ID:" + receiverId + "]");
ackMsg.setSendTime(LocalDateTime.now());
senderSession.sendMessage(new TextMessage(JSON.toJSONString(ackMsg)));
}
/**
* 处理接收通知(广播)
* @param message 消息对象
* @param session 发送者会话
*/
private void handleReceiveNotify(WsMessage message, WebSocketSession session) throws IOException {
// 广播消息给所有在线用户(除发送者自己)
for (Session targetSession : wsSessionManager.getAllSessions()) {
if (!targetSession.getId().equals(session.getId()) && targetSession.isOpen()) {
targetSession.getBasicRemote().sendText(JSON.toJSONString(message));
}
}
// 发送广播成功回执
WsMessage ackMsg = new WsMessage();
ackMsg.setMessageId(UUID.randomUUID().toString());
ackMsg.setMsgType("BROADCAST_ACK");
ackMsg.setContent("通知已广播给所有在线用户");
ackMsg.setSendTime(LocalDateTime.now());
session.sendMessage(new TextMessage(JSON.toJSONString(ackMsg)));
}
/**
* 连接关闭时清理资源
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
LoginUser loginUser = (LoginUser) session.getAttributes().get("loginUser");
if (loginUser != null) {
wsSessionManager.removeSession(loginUser.getUserId());
}
}
}
七、完整测试与验证
为验证权限控制效果,我们通过实际测试场景对比不同用户的权限表现,确保 HTTP 和 WebSocket 权限控制生效。
7.1 测试准备
- 启动服务:确保 MySQL、Redis 正常运行,启动 Spring Boot 应用;
- 获取 Token :
- 使用 admin 用户登录(用户名:admin,密码:123456),获取 Token;
- 使用 test 用户登录(用户名:test,密码:123456),获取 Token。
7.2 HTTP 接口权限测试
7.2.1 测试接口:查询用户列表(/api/v1/users)
- 权限要求 :需要
sys:user:list权限(仅 admin 拥有)。 - 测试步骤 :
- 使用 admin 的 Token 调用接口,预期返回 200 成功;
- 使用 test 的 Token 调用接口,预期返回 403 无权限。
7.2.2 测试接口:获取当前用户信息(/api/v1/users/current)
- 权限要求:无需特定权限,已登录用户均可访问。
- 测试步骤 :
- 使用 admin 或 test 的 Token 调用接口,预期均返回 200 成功。
7.3 WebSocket 权限测试
使用 WebSocket 客户端工具(如 wscat)连接测试:
7.3.1 连接认证测试
# 使用无效 Token 连接(预期失败)
wscat -c "ws://localhost:8080/ws?token=invalid"
# 输出:连接被拒绝(握手失败)
# 使用 admin 的 Token 连接(预期成功)
wscat -c "ws://localhost:8080/ws?token=admin_token_here"
# 输出:收到 "WebSocket 连接成功" 消息
# 使用 test 的 Token 连接(预期成功)
wscat -c "ws://localhost:8080/ws?token=test_token_here"
# 输出:收到 "WebSocket 连接成功" 消息
7.3.2 消息权限测试
-
发送消息(SEND_MSG) :需要
sys:ws:send权限(仅 admin 拥有)。json
// admin 发送消息(预期成功) { "msgType": "SEND_MSG", "content": "Hello", "receiverId": 2 } // 输出:收到 "消息已发送" 回执 // test 发送消息(预期失败) { "msgType": "SEND_MSG", "content": "Hi", "receiverId": 1 } // 输出:收到 "无权限" 消息,连接被关闭 -
接收通知(RECEIVE_NOTIFY) :需要
sys:ws:receive权限(admin 和 test 均拥有)。// admin 发送广播(预期成功) { "msgType": "RECEIVE_NOTIFY", "content": "系统通知:服务器将重启" } // 输出:test 客户端收到广播消息 // test 发送广播(预期成功) { "msgType": "RECEIVE_NOTIFY", "content": "我上线了" } // 输出:admin 客户端收到广播消息
八、进阶优化与注意事项
8.1 性能优化
- 权限缓存 :通过 Redis 缓存用户权限(
@Cacheable注解已实现),减少数据库查询; - 会话管理:使用 ConcurrentHashMap 存储会话,确保线程安全的同时提升查询效率;
- 批量操作:WebSocket 广播时采用批量发送,减少 I/O 次数。
8.2 安全增强
-
Token 黑名单 :维护已注销但未过期的 Token 黑名单,防止被盗用;
// Redis 存储黑名单,key: blacklist:{token}, value: 过期时间 public boolean isTokenBlacklisted(String token) { return redisTemplate.hasKey("blacklist:" + token); } -
消息加密:对敏感消息内容进行加密传输(如 AES 加密);
-
频率限制:限制单位时间内的消息发送次数,防止恶意攻击。
8.3 动态权限刷新
当用户权限发生变化时,需实时更新缓存和会话中的权限信息:
/**
* 刷新用户权限(权限变更时调用)
* @param userId 用户ID
*/
public void refreshUserPermission(Long userId) {
// 清除权限缓存
redisTemplate.delete("permCache::user:" + userId);
// 重新加载权限
Set<String> newPerms = sysUserService.getUserPermCodes(userId);
// 更新会话中的权限信息
WebSocketSession session = (WebSocketSession) wsSessionManager.getSession(userId);
if (session != null) {
LoginUser loginUser = (LoginUser) session.getAttributes().get("loginUser");
loginUser.setPermCodes(newPerms);
}
}
8.4 常见问题与解决方案
-
WebSocket 连接超时:
- 原因:服务器或负载均衡器对长连接有超时限制;
- 解决:客户端定期发送心跳消息(如每 30 秒发送一次
PING类型消息)。
-
权限缓存不一致:
- 原因:权限变更后缓存未及时更新;
- 解决:权限修改接口添加缓存清理逻辑,或设置合理的缓存过期时间。
-
大量连接导致内存溢出:
- 原因:会话未及时清理,积累过多;
- 解决:设置连接最大空闲时间,超时自动关闭;定期检查并清理无效会话。
九、总结
本文基于 RBAC 模型,构建了一套同时支持 HTTP 和 WebSocket 的统一权限控制体系,核心要点包括:
- 认证统一:通过 JWT 实现两种通信方式的身份认证,避免重复开发;
- 授权统一:基于用户 - 角色 - 权限关系,集中管理权限规则;
- 鉴权适配:HTTP 采用拦截器 + 注解鉴权,WebSocket 采用握手拦截 + 消息拦截鉴权,适配各自通信特性;
- 实战验证:通过完整代码示例和测试场景,验证了权限控制的有效性。
在实际项目中,可根据业务需求扩展权限粒度(如数据级权限),或集成 OAuth2.0/OpenID Connect 实现更复杂的认证场景。权限控制是系统安全的基石,合理设计并严格执行,才能有效防范未授权访问风险。

