从认证到数据权限:SpringBoot3 + Sa-Token + AOP实现企业级安全体系
一、问题背景
做企业级应用开发这些年,我见过太多项目把"安全"简化成"加个登录就行"。登录验证只是安全体系的第一道门,真正的企业级安全是一个多层防护体系。之前在给政府客户交付项目时,安全评审环节就遇到过一个尴尬:系统有登录,但同一个部门的用户能互相看到对方的数据,审计日志里找不到任何操作记录,XSS漏洞扫描扫出一堆的中危问题。
为什么需要多层安全防护?因为攻击者不会只盯着登录接口。一个完整的企业级安全体系至少需要覆盖这几个场景:
密码在传输过程中被截获怎么办?——传输加密
登录凭证过期了怎么处理?——认证鉴权
用户登录后能访问哪些功能?——接口权限
用户能看到哪些数据?——数据权限
恶意请求如何防御?——安全防护层
YSCode(忆笙智云)的安全体系设计从立项之初就考虑了多层防护。
二、安全体系全景
整个安全体系分为四层,每层之间相互独立又依次递进:
层级 | 模块 | 技术实现 | 说明 |
|---|---|---|---|
第一层 | 传输加密 | 前端SM4国密算法加密 | 防止中间人抓包获取明文密码 |
第二层 | 认证鉴权 | Sa-Token 1.43.0 JWT Simple模式 + Redis | 登录验证、Token生成、会话管理 |
第三层 | 权限控制 | RBAC(用户-角色-菜单) + 数据权限AOP | 功能权限 + 数据范围双重控制 |
第四层 | 安全防护 | CSRF/ XSS/ 演示模式/ IP黑白名单 | 防御常见Web攻击和越权操作 |
这四层构成了一个纵深防御体系。单点失效不会导致整体安全崩溃,每一层都提供了独立的安全保障。
三、传输加密层:为什么用SM4
登录接口的密码传输是安全的第一道防线。即使上了HTTPS,某些内网环境下(比如公司网络出口有SSL解密设备)抓包工具仍然可能截获请求体。SM4是国密对称加密算法,在前端对密码字段加密后再传输,即使请求被截获也无法还原明文密码。
选择SM4而不是AES的原因:政府项目、金融项目、国企项目中,国密算法是硬性要求。SM4在安全性和合规性上都满足要求。
前端加密逻辑(简化):
// login.vue - 登录时密码加密 import { sm4 } from '@/utils/encrypt'; const login = async () => { // 1. SM4加密密码 const encryptedPassword = sm4.encrypt(loginForm.password); // 2. 发送加密后的登录请求 const res = await loginApi({ username: loginForm.username, password: encryptedPassword, uuid: captchaKey, // 验证码唯一标识 code: loginForm.code, // 验证码 }); // 3. 存储Token备用 localStorage.setItem('token', res.data.token); // 4. 跳转到首页 router.push('/'); };SM4加密工具类实现(前端 TypeScript):
// utils/encrypt.ts import { SM4 } from 'gm-crypto'; const SM4_KEY = 'your-16-byte-key!'; // 16字节密钥,与后端保持一致 export const sm4 = { encrypt(plainText: string): string { return SM4.encrypt(plainText, SM4_KEY, { inputEncoding: 'utf8', outputEncoding: 'base64', }); }, decrypt(cipherText: string): string { return SM4.decrypt(cipherText, SM4_KEY, { inputEncoding: 'base64', outputEncoding: 'utf8', }); }, };四、认证流程:从密码验证到Token生成
用户登录的完整链路:
前端Vue → SM4加密密码 → POST /login 后端 → SM4解密 → BCrypt密码比对 → Sa-Token生成JWT → Redis存储会话 返回 → JWT Token → 前端localStorage存储 → 后续请求Header携带后端解密和验证:
@PostMapping("/login") public R<LoginResult> login(@RequestBody LoginBody body) { // 1. SM4解密密码 String rawPassword = Sm4Util.decrypt(body.getPassword()); // 2. BCrypt密码验证 SysUser user = userService.getByUsername(body.getUsername()); if (user == null) { throw new BusinessException("用户不存在"); } if (!BCrypt.checkpw(rawPassword, user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 3. 检查用户状态 if ("0".equals(user.getStatus())) { throw new BusinessException("账号已被停用"); } // 4. Sa-Token生成JWT并存入Redis StpUtil.login(user.getId()); String token = StpUtil.getTokenValue(); // 5. 记录登录信息 user.setLoginIp(ServletUtils.getClientIP()); user.setLoginDate(new Date()); userService.updateById(user); // 6. 返回Token和用户信息 return R.ok(LoginResult.builder() .token(token) .userInfo(convert(user)) .build()); }关键点说明:
BCrypt是单向哈希,每次加密的盐值不同,即使相同密码每次生成的密文也不同。这意味着即使数据库泄露,攻击者也无法通过彩虹表还原密码
Sa-Token JWT Simple模式将Token作为JWT payload,同时保持Session存储在Redis中。纯JWT方案无法主动踢人下线,而Sa-Token的混合模式兼顾了JWT的无状态优势和服务端会话管理的灵活性
Redis存储会话信息,支持分布式部署下多节点共享登录状态,服务重启不会丢失会话
Sa-Token配置(application.yml):
sa-token: token-name: Authorization timeout: 2592000 # Token有效期30天 active-timeout: 1800 # 30分钟无操作自动下线 is-concurrent: false # 不允许同一账号并发登录 is-share: false # 多端不共享会话 token-style: simple # Simple风格Token is-log: true # 开启日志 jwt-secret-key: ${JWT_SECRET} # JWT密钥,从环境变量读取五、权限体系:RBAC + 注解式权限控制
权限模型采用标准的RBAC(用户 → 角色 → 菜单):
SysUser(用户) ──多对多──▶ SysRole(角色) ──多对多──▶ SysMenu(菜单/按钮)菜单权限分为三个粒度,从粗到细:
权限类型 | 控制范围 | 示例 | 实现方式 |
|---|---|---|---|
菜单权限 | 控制左侧菜单可见性 | 用户管理菜单是否显示 | 后端返回菜单树时过滤 |
按钮权限 | 控制页面内按钮显示 | 新增、编辑、删除按钮 | 前端 v-auth 指令 |
接口权限 | 控制后端API访问 | 控制器方法级别的权限校验 | @SaCheckPermission 注解 |
后端接口权限控制示例:
@RestController @RequestMapping("/system/user") public class SysUserController { // 查看用户列表 - 需要 system:user:list 权限 @SaCheckPermission("system:user:list") @GetMapping("/list") public R<TableDataInfo> list(SysUser user, PageQuery pageQuery) { return R.ok(userService.selectPageUserList(user, pageQuery)); } // 查看用户详情 - 需要 system:user:query 权限 @SaCheckPermission("system:user:query") @GetMapping("/{userId}") public R<SysUser> getInfo(@PathVariable Long userId) { return R.ok(userService.selectUserById(userId)); } // 新增用户 - 需要 system:user:add 权限 @SaCheckPermission("system:user:add") @PostMapping public R<Void> add(@Validated @RequestBody SysUser user) { // 密码加密 user.setPassword(BCrypt.hashpw(user.getPassword())); userService.insertUser(user); return R.ok(); } // 编辑用户 - 需要 system:user:edit 权限 @SaCheckPermission("system:user:edit") @PutMapping public R<Void> edit(@Validated @RequestBody SysUser user) { userService.updateUser(user); return R.ok(); } // 删除用户 - 需要 system:user:remove 权限 @SaCheckPermission("system:user:remove") @DeleteMapping("/{userIds}") public R<Void> remove(@PathVariable Long[] userIds) { userService.deleteUserByIds(userIds); return R.ok(); } // 导出用户 - 需要 system:user:export 权限 @SaCheckPermission("system:user:export") @PostMapping("/export") public void export(HttpServletResponse response, SysUser user) { List<SysUser> list = userService.selectUserList(user); ExcelUtil.exportExcel(response, list, "用户数据"); } }Sa-Token的@SaCheckPermission注解还支持角色校验和逻辑组合:
// 单一权限校验 @SaCheckPermission("system:user:list") // 多权限OR组合(满足任一即可) @SaCheckPermission( value = {"system:user:add", "system:user:edit"}, mode = SaMode.OR ) // 多权限AND组合(必须全部满足) @SaCheckPermission( value = {"system:user:list", "system:user:add"}, mode = SaMode.AND ) // 角色校验 @SaCheckRole("admin")六、数据权限:AOP注解式拦截
接口权限控制的是"能不能访问这个功能",数据权限控制的是"能看到哪些数据"。举个例子:部门经理只能看到本部门的数据,普通员工只能看到自己的数据,总经理能看到全部数据。
数据权限的实现基于AOP注解 + SQL自动拼接,无需在每个查询方法中手写过滤条件:
// 数据权限注解 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { // 部门表别名 String deptAlias() default ""; // 用户表别名 String userAlias() default ""; // 权限字段名 String permissionField() default "dataScope"; }AOP切面逻辑(核心部分):
@Aspect @Component public class DataScopeAspect { @Before("@annotation(dataScope)") public void doBefore(JoinPoint point, DataScope dataScope) { // 1. 获取当前登录用户 LoginUser loginUser = SecurityUtils.getLoginUser(); SysUser currentUser = loginUser.getUser(); // 2. 超级管理员跳过数据权限过滤 if (loginUser.isAdmin()) { return; } // 3. 构建数据权限过滤SQL StringBuilder sqlString = new StringBuilder(); String roleIds = loginUser.getRoleIds(); // 全部数据权限 - 不添加任何过滤 if (DataScopeType.ALL.getCode().equals(currentUser.getDataScope())) { return; } // 自定义数据权限 - 根据角色配置的部门范围过滤 else if (DataScopeType.CUSTOM.getCode().equals(currentUser.getDataScope())) { sqlString.append(String.format( " OR %s.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id IN (%s))", dataScope.deptAlias(), roleIds)); } // 本部门数据权限 else if (DataScopeType.DEPT.getCode().equals(currentUser.getDataScope())) { sqlString.append(String.format( " OR %s.dept_id = %d", dataScope.deptAlias(), currentUser.getDeptId())); } // 本部门及以下数据权限 ... // 仅本人数据权限 ... // 4. 将SQL条件注入到分页参数中 // 去掉开头的" OR ",用" AND ("包裹 if (sqlString.length() > 0) { String condition = sqlString.substring(4); // 遍历方法参数,找到PageQuery对象 ... } } }使用方式非常简洁,在Service方法上添加一行注解即可:
// 在Service方法上添加注解 @DataScope(deptAlias = "d", userAlias = "u") public List<SysUser> selectUserList(SysUser user) { return userMapper.selectUserList(user); }对应的Mapper XML中,通过${params.dataScope}动态拼接过滤条件:
<select id="selectUserList" resultType="SysUser"> SELECT u.user_id, u.username, u.nick_name, u.email, u.phone, u.status, u.create_time, d.dept_name FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id = d.dept_id WHERE u.del_flag = '0' <if test="username != null and username != ''"> AND u.username LIKE CONCAT('%', #{username}, '%') </if> <if test="status != null and status != ''"> AND u.status = #{status} </if> <!-- 数据权限条件自动拼接 --> ${params.dataScope} ORDER BY u.create_time DESC </select>数据权限的五种类型:
权限类型 | 常量 | 数据范围 | 适用角色 |
|---|---|---|---|
全部数据 | ALL | 查看所有数据 | 超级管理员 |
自定义数据 | CUSTOM | 按角色配置的部门范围 | 分管领导 |
本部门数据 | DEPT | 仅本部门数据 | 部门经理 |
本部及以下 | DEPT_AND_CHILD | 本部门和所有子部门 | 部门负责人 |
仅本人数据 | SELF | 仅自己创建的数据 | 普通员工 |
七、安全防护层
CSRF Token校验
CSRF(跨站请求伪造)攻击原理:攻击者诱导用户点击恶意链接,利用用户已登录的Session发起伪造请求。YS-LowCode通过CSRF Token机制防御:
public class CsrfInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // GET请求不校验CSRF if ("GET".equalsIgnoreCase(request.getMethod())) { return true; } // 从请求头获取CSRF Token String csrfToken = request.getHeader("X-CSRF-TOKEN"); // 从Redis获取服务端CSRF Token String serverToken = StpUtil.getSession() .getString("CSRF_TOKEN"); if (StringUtils.isBlank(csrfToken) || !csrfToken.equals(serverToken)) { log.warn("CSRF Token校验失败: IP={}, URI={}", ServletUtils.getClientIP(), request.getRequestURI()); throw new BusinessException("CSRF Token校验失败,请刷新页面后重试"); } return true; } }XSS过滤器
通过自定义的XssHttpServletRequestWrapper对请求参数进行过滤,防止恶意脚本注入:
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { @Override public String[] getParameterValues(String name) { String[] values = super.getParameterValues(name); if (values == null) return null; return Arrays.stream(values) .map(this::clean) .toArray(String[]::new); } @Override public String getParameter(String name) { return clean(super.getParameter(name)); } @Override public String getHeader(String name) { return clean(super.getHeader(name)); } private String clean(String value) { if (StringUtils.isBlank(value)) return value; // 过滤script标签及其内容 value = value.replaceAll("(?i)<script.*?>.*?</script>", ""); // 过滤iframe标签 value = value.replaceAll("(?i)<iframe.*?>.*?</iframe>", ""); // 过滤on事件属性 value = value.replaceAll("(?i)on\\w+\\s*=", "_disabled="); // 过滤javascript:伪协议 value = value.replaceAll("(?i)javascript:", ""); // 过滤expression表达式 value = value.replaceAll("(?i)expression\\s*\\(", ""); // 过滤HTML标签 value = value.replaceAll("<[^>]*>", ""); return value; } }XSS过滤器注册:
@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<XssFilter> xssFilterRegistration() { FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new XssFilter()); registration.addUrlPatterns("/*"); registration.setName("xssFilter"); registration.setOrder(1); // 排除不需要过滤的路径 registration.addInitParameter("excludes", "/system/notice/*"); return registration; } }演示模式拦截器
开放演示环境时,需要拦截所有写操作,防止数据被恶意篡改:
@Component public class DemoModeInterceptor implements HandlerInterceptor { @Value("${demo.enabled:false}") private boolean demoEnabled; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!demoEnabled) return true; // 演示模式下,非GET请求全部拦截 if (!"GET".equalsIgnoreCase(request.getMethod())) { // 排除登录接口 String uri = request.getRequestURI(); if (uri.contains("/login")) { return true; } throw new BusinessException("演示模式,不允许执行写操作"); } return true; } }IP黑白名单
通过配置项精确控制访问来源,支持单个IP和CIDR网段:
# application.yml security: ip-whitelist: enabled: true ips: 192.168.1.0/24,10.0.0.1,10.0.0.2 ip-blacklist: enabled: true ips: 58.218.204.100,185.220.101.0/24IP黑白名单实现:
@Component public class IpFilter implements Filter { @Value("${security.ip-whitelist.enabled:false}") private boolean whitelistEnabled; @Value("${security.ip-whitelist.ips:}") private String whitelistIps; @Value("${security.ip-blacklist.enabled:false}") private boolean blacklistEnabled; @Value("${security.ip-blacklist.ips:}") private String blacklistIps; private List<SubnetUtils> whitelistSubnets; private List<SubnetUtils> blacklistSubnets; @PostConstruct public void init() { whitelistSubnets = parseSubnets(whitelistIps); blacklistSubnets = parseSubnets(blacklistIps); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String clientIp = ServletUtils.getClientIP((HttpServletRequest) request); // 先检查黑名单 if (blacklistEnabled && isInSubnets(clientIp, blacklistSubnets)) { log.warn("IP黑名单拦截: {}", clientIp); ((HttpServletResponse) response).sendError(403, "IP已被禁止访问"); return; } // 再检查白名单 if (whitelistEnabled && !isInSubnets(clientIp, whitelistSubnets)) { log.warn("IP白名单拦截: {}", clientIp); ((HttpServletResponse) response).sendError(403, "IP不在允许范围内"); return; } chain.doFilter(request, response); } private boolean isInSubnets(String ip, List<SubnetUtils> subnets) { if (subnets.isEmpty()) return false; return subnets.stream().anyMatch(subnet -> { try { return subnet.getInfo().isInRange(ip); } catch (Exception e) { return false; } }); } }八、前端权限控制:Auth组件
前端通过Auth组件实现三种权限控制方式,覆盖所有使用场景:
方式一:v-auth指令(最简洁)
<template> <div class="user-management"> <el-button v-auth="'system:user:add'" type="primary" icon="Plus">新增</el-button> <el-button v-auth="'system:user:import'" type="success" icon="Upload">导入</el-button> <el-button v-auth="'system:user:export'" type="warning" icon="Download">导出</el-button> <el-button v-auth="'system:user:remove'" type="danger" icon="Delete">删除</el-button> </div> </template>v-auth指令实现原理:
// directives/auth.ts import type { Directive, DirectiveBinding } from 'vue'; import { useUserStore } from '@/store/user'; export const auth: Directive = { mounted(el: HTMLElement, binding: DirectiveBinding) { const { value } = binding; const userStore = useUserStore(); const permissions = userStore.permissions; if (value && value instanceof Array && value.length > 0) { const hasPermission = value.some(v => permissions.includes(v)); if (!hasPermission) { el.parentNode?.removeChild(el); } } else if (value) { const hasPermission = permissions.includes(value); if (!hasPermission) { el.parentNode?.removeChild(el); } } else { throw new Error('v-auth 指令需要传入权限标识'); } }, };方式二:auth函数(用于JS逻辑判断)
import { hasPermi, hasRole } from '@/utils/auth'; // 判断是否有某个权限 if (hasPermi('system:user:edit')) { // 执行编辑操作 dialogVisible.value = true; } // 判断是否有某个角色 if (hasRole('admin')) { // 管理员才能执行的操作 showAdminMenu.value = true; } // 判断是否同时拥有多个权限 if (hasPermi(['system:user:add', 'system:user:edit'])) { // 同时拥有新增和编辑权限 }权限判断函数实现:
// utils/auth.ts import { useUserStore } from '@/store/user'; export function hasPermi(permission: string | string[]): boolean { const userStore = useUserStore(); const permissions = userStore.permissions; if (Array.isArray(permission)) { // AND模式:必须同时拥有所有权限 return permission.every(p => permissions.includes(p)); } return permissions.includes(permission); } export function hasRole(role: string | string[]): boolean { const userStore = useUserStore(); const roles = userStore.roles; if (Array.isArray(role)) { return role.some(r => roles.includes(r)); } return roles.includes(role); }方式三:Auth组件(包裹多个元素)
<template> <Auth :value="'system:user:export'"> <el-button type="warning">批量导出</el-button> <el-divider direction="vertical" /> <el-button type="primary">批量审核</el-button> </Auth> </template>这三种方式的设计逻辑:v-auth指令适合单个元素的权限控制,auth函数适合JS逻辑中的条件判断,Auth组件适合需要包裹多个元素统一控制的场景。三者互补,覆盖了前端所有权限控制需求。
九、总结
一个企业级安全体系不是简单的"登录 + 权限注解",而是从传输加密、认证鉴权、接口权限、数据权限、安全防护多个维度构建的纵深防御体系。YS-LowCode的安全方案覆盖了这五个维度:SM4国密加密保证传输安全,Sa-Token JWT + Redis保证认证可靠,RBAC + AOP数据权限保证授权精细,CSRF/XSS/IP黑白名单保证防护全面。
这套方案在政府和企业项目中经过了多次安全评审的实际检验,可以直接用于生产环境。
