从认证到数据权限: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 RLoginResult 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.ymlsa-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 RTableDataInfo list(SysUser user, PageQuery pageQuery) { return R.ok(userService.selectPageUserList(user, pageQuery)); } // 查看用户详情 - 需要 system:user:query 权限 SaCheckPermission(system:user:query) GetMapping(/{userId}) public RSysUser getInfo(PathVariable Long userId) { return R.ok(userService.selectUserById(userId)); } // 新增用户 - 需要 system:user:add 权限 SaCheckPermission(system:user:add) PostMapping public RVoid 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 RVoid edit(Validated RequestBody SysUser user) { userService.updateUser(user); return R.ok(); } // 删除用户 - 需要 system:user:remove 权限 SaCheckPermission(system:user:remove) DeleteMapping(/{userIds}) public RVoid 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) { ListSysUser 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 ListSysUser selectUserList(SysUser user) { return userMapper.selectUserList(user); }对应的Mapper XML中通过${params.dataScope}动态拼接过滤条件select idselectUserList resultTypeSysUser 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 testusername ! null and username ! AND u.username LIKE CONCAT(%, #{username}, %) /if if teststatus ! 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 FilterRegistrationBeanXssFilter xssFilterRegistration() { FilterRegistrationBeanXssFilter 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 ListSubnetUtils whitelistSubnets; private ListSubnetUtils 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, ListSubnetUtils 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 classuser-management el-button v-authsystem:user:add typeprimary iconPlus新增/el-button el-button v-authsystem:user:import typesuccess iconUpload导入/el-button el-button v-authsystem:user:export typewarning iconDownload导出/el-button el-button v-authsystem:user:remove typedanger iconDelete删除/el-button /div /templatev-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 :valuesystem:user:export el-button typewarning批量导出/el-button el-divider directionvertical / el-button typeprimary批量审核/el-button /Auth /template这三种方式的设计逻辑v-auth指令适合单个元素的权限控制auth函数适合JS逻辑中的条件判断Auth组件适合需要包裹多个元素统一控制的场景。三者互补覆盖了前端所有权限控制需求。九、总结一个企业级安全体系不是简单的登录 权限注解而是从传输加密、认证鉴权、接口权限、数据权限、安全防护多个维度构建的纵深防御体系。YS-LowCode的安全方案覆盖了这五个维度SM4国密加密保证传输安全Sa-Token JWT Redis保证认证可靠RBAC AOP数据权限保证授权精细CSRF/XSS/IP黑白名单保证防护全面。这套方案在政府和企业项目中经过了多次安全评审的实际检验可以直接用于生产环境。