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 实现更复杂的认证场景。权限控制是系统安全的基石,合理设计并严格执行,才能有效防范未授权访问风险。

相关推荐
知兀9 小时前
【MybatisPlus】后端用枚举类,数据库用tinyint,存在枚举类型转换
java
StockTV9 小时前
印度股票实时数据 NSE和BSE的实时行情、K 线及指数数据
java·开发语言·spring boot·python
User_芊芊君子9 小时前
【OpenAI 把 AI 玩明白了】:自主推理 + 动态知识图谱,这 4 个技术突破要颠覆行业
java·人工智能·知识图谱
c++之路9 小时前
C++20概述
java·开发语言·c++20
Championship.23.249 小时前
Linux Top 命令族深度解析与实战指南
java·linux·服务器·top·linux调试
橘子海全栈攻城狮10 小时前
【最新源码】养老院系统管理A013
java·spring boot·后端·web安全·微信小程序
逻辑驱动的ken10 小时前
Java高频面试考点18
java·开发语言·数据库·算法·面试·职场和发展·哈希算法
冷雨夜中漫步10 小时前
Claude Code源码分析——Claude Code Agent Loop 详细设计文档
java·开发语言·人工智能·ai
直奔標竿10 小时前
Java开发者AI转型第二十六课!Spring AI 个人知识库实战(五)——联网搜索增强实战
java·开发语言·人工智能·spring boot·后端·spring
one_love_zfl11 小时前
java面试-微服务组件篇
java·微服务·面试