Java 项目 HTTP+WebSocket 统一权限控制实战

在现代 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 是 "连接级 + 消息级" 的双重校验 ------ 连接建立时的认证决定是否允许接入,消息传输时的鉴权决定是否允许操作。

二、基础架构设计:统一权限控制体系

要解决两套通信方式的权限协同问题,核心是搭建 "统一认证、统一授权、分场景鉴权" 的架构。整体设计遵循以下原则:

  1. 认证统一:HTTP 和 WebSocket 共用一套身份认证机制(基于 JWT),避免重复开发;
  2. 授权统一:基于 RBAC 模型(用户 - 角色 - 权限),权限规则集中管理,支持接口级、功能级权限控制;
  3. 鉴权灵活:HTTP 采用 "拦截器 + 注解" 鉴权,WebSocket 采用 "握手拦截 + 消息拦截" 鉴权,适配各自通信特性;
  4. 状态可控:长连接的用户身份、权限信息与连接绑定,支持动态权限刷新、连接销毁时的资源释放。

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 测试准备

  1. 启动服务:确保 MySQL、Redis 正常运行,启动 Spring Boot 应用;
  2. 获取 Token
    • 使用 admin 用户登录(用户名:admin,密码:123456),获取 Token;
    • 使用 test 用户登录(用户名:test,密码:123456),获取 Token。

7.2 HTTP 接口权限测试

7.2.1 测试接口:查询用户列表(/api/v1/users)
  • 权限要求 :需要 sys:user:list 权限(仅 admin 拥有)。
  • 测试步骤
    1. 使用 admin 的 Token 调用接口,预期返回 200 成功;
    2. 使用 test 的 Token 调用接口,预期返回 403 无权限。
7.2.2 测试接口:获取当前用户信息(/api/v1/users/current)
  • 权限要求:无需特定权限,已登录用户均可访问。
  • 测试步骤
    1. 使用 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 性能优化

  1. 权限缓存 :通过 Redis 缓存用户权限(@Cacheable 注解已实现),减少数据库查询;
  2. 会话管理:使用 ConcurrentHashMap 存储会话,确保线程安全的同时提升查询效率;
  3. 批量操作:WebSocket 广播时采用批量发送,减少 I/O 次数。

8.2 安全增强

  1. Token 黑名单 :维护已注销但未过期的 Token 黑名单,防止被盗用;

    复制代码
    // Redis 存储黑名单,key: blacklist:{token}, value: 过期时间
    public boolean isTokenBlacklisted(String token) {
        return redisTemplate.hasKey("blacklist:" + token);
    }
  2. 消息加密:对敏感消息内容进行加密传输(如 AES 加密);

  3. 频率限制:限制单位时间内的消息发送次数,防止恶意攻击。

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 常见问题与解决方案

  1. WebSocket 连接超时

    • 原因:服务器或负载均衡器对长连接有超时限制;
    • 解决:客户端定期发送心跳消息(如每 30 秒发送一次 PING 类型消息)。
  2. 权限缓存不一致

    • 原因:权限变更后缓存未及时更新;
    • 解决:权限修改接口添加缓存清理逻辑,或设置合理的缓存过期时间。
  3. 大量连接导致内存溢出

    • 原因:会话未及时清理,积累过多;
    • 解决:设置连接最大空闲时间,超时自动关闭;定期检查并清理无效会话。

九、总结

本文基于 RBAC 模型,构建了一套同时支持 HTTP 和 WebSocket 的统一权限控制体系,核心要点包括:

  1. 认证统一:通过 JWT 实现两种通信方式的身份认证,避免重复开发;
  2. 授权统一:基于用户 - 角色 - 权限关系,集中管理权限规则;
  3. 鉴权适配:HTTP 采用拦截器 + 注解鉴权,WebSocket 采用握手拦截 + 消息拦截鉴权,适配各自通信特性;
  4. 实战验证:通过完整代码示例和测试场景,验证了权限控制的有效性。

在实际项目中,可根据业务需求扩展权限粒度(如数据级权限),或集成 OAuth2.0/OpenID Connect 实现更复杂的认证场景。权限控制是系统安全的基石,合理设计并严格执行,才能有效防范未授权访问风险。

相关推荐
7澄12 小时前
深入解析 LeetCode 数组经典问题:删除每行中的最大值与找出峰值
java·开发语言·算法·leetcode·intellij idea
进击的圆儿2 小时前
HTTP协议深度解析:从基础到性能优化
网络协议·http·性能优化
ysyxg2 小时前
设计模式-策略模式
java·开发语言
Felix_XXXXL3 小时前
Spring Security安全框架原理与实战
java·后端
一抓掉一大把3 小时前
秒杀-StackExchangeRedisHelper连接单例
java·开发语言·jvm
升鲜宝供应链及收银系统源代码服务3 小时前
升鲜宝生鲜配送供应链管理系统--- 《多语言商品查询优化方案(Redis + 翻译表 + 模糊匹配)》
java·数据库·redis·bootstrap·供应链系统·生鲜配送·生鲜配送源代码
青山的青衫3 小时前
【JavaWeb】Tlias后台管理系统
java·web
蒟蒻的工具人3 小时前
SSE实时推送订单状态
java·eventsource·sse协议
小蒜学长3 小时前
springboot基于Java的校园导航微信小程序的设计与实现(代码+数据库+LW)
java·spring boot·后端·微信小程序