凌晨两点,一个做综合管理平台运维的朋友发来消息:审计报表里出现了一条不该出现的记录——一个只有实时预览权限的普通操作账号,在十分钟内完成了一次系统级口令变更。他第一反应是账号被盗,查完登录日志发现来源地址没变、设备指纹没变、操作时间就是那个人的排班时段。账号没被盗,是平台自己没拦住。
这件事让我重新审视了很多管理平台在账户权限设计上的通病。很多团队在部署一套带多层级账户的管理系统时,注意力全放在业务功能上,越权校验、密码管理、系统口令的变更链路往往被当成“内部功能”草草了事。等到某天一个低权限账号做出了管理员才该做的动作,才发现问题不在密码模块本身,而在权限校验的位置选错了。这篇内容面向的是负责这类平台部署、运维和安全自查的同行,从权限模型、校验位置、改密链路、自查方法到加固落地,把我这几年踩过的和见过的东西完整摊开讲一遍。
1. 权限没拦住的那次改密,根因不在密码模块
先把结论放在前面:当出现“低权限账号能改系统口令”这类现象时,绝大多数人的第一反应是去查密码加密算法、去查数据库里存的哈希对不对。这条路走偏了。密码模块通常只负责“口令怎么存、怎么比、怎么改”,它不负责判断“你有资格改谁的口令”。真正缺的那道门,在权限校验层。
1.1 从一条审计记录倒推:谁有资格碰系统口令
拿到那条异常记录之后,我做的第一件事不是看改密接口的代码,而是把整个平台的账户体系在纸上画了一遍。画完发现问题很清晰:这套平台里存在三类完全不同的账户,但它们在权限表里被混在了一张表上。
- 业务操作账号:用来登录、看画面、查记录、导出报表,日常使用频率最高,往往一人一号,权限配得比较随意。
- 平台管理账号:负责建用户、配角色、调参数、看日志,属于管理面。
- 系统级账户:操作系统层面的运行账户,平台自身用它来读写配置文件、启停服务、操作底层资源。
问题在于,平台的改密功能在设计时把“修改系统级账户口令”当成了一项普通配置项,挂在管理菜单下面,而菜单的可见性靠前端按钮的显示隐藏来控制。低权限账号登录后看不到那个按钮,但接口本身没有做角色判断——只要请求发得出来,服务端就照做。
这就是典型的一句话:按钮藏起来了,门没锁。
1.2 权限校验失败的三种典型形态
我后来把这类问题归了归类,实际遇到的几乎都落在下面三种形态里,可以用一张表对照着自查。
| 形态 | 表现 | 根因 | 排查切入点 |
|---|---|---|---|
| 校验缺失 | 接口完全没有角色判断 | 开发时把功能当内部接口,默认“没人会调” | 逐接口核对是否有权限注解或拦截 |
| 校验错位 | 接口有校验,但校验的是“是否登录”而非“是否有该权限” | 只做了认证没做授权 | 看拦截器里判断的是会话还是权限码 |
| 校验降级 | 权限判断写在业务分支里,某个异常分支提前返回了成功 | 条件分支覆盖不全 | 走读异常路径与默认返回值 |
第一种最粗暴,也最容易被漏掉,因为它往往藏在“系统设置”“高级配置”“维护工具”这类看起来只有管理员会点进去的菜单里。第二种最隐蔽,日志上看每条请求都“通过了鉴权”,实际通过的是身份鉴权而不是权限鉴权。第三种最少见但最麻烦,因为从代码结构上看它是有校验的,只有把异常分支全部走一遍才能发现。
提示:把“认证”和“授权”两个词在团队内部统一口径,能省掉很多沟通成本。认证解决“你是谁”,授权解决“你能干什么”,两者在代码里必须是两段独立逻辑。
2. 三层账户模型:为什么“低权限”这个词本身就含糊
那次排查里最耗时间的不是找代码,而是跟人对齐“低权限”到底指什么。业务同事说的低权限,是“只能看不能改”;运维同事说的低权限,是“不是 root”;开发同事说的低权限,是“没给管理员角色”。三个口径完全不是一回事。
2.1 业务角色、平台管理员、系统账号是三套东西
一套成熟的管理平台,账户体系至少要在概念上分成三层,并且这三层之间的边界必须是硬的。
第一层是业务角色层。这一层用 RBAC 来管就够了,角色对应一组操作权限码,用户的权限等于其所有角色的权限并集。关键在于角色划分要按“操作域”而不是按“部门”来切,因为按部门切出来的角色往往权限过大且无法复用。
第二层是平台管理层。这一层的权限不能通过角色继承获得,必须是显式授予。我见过太多平台把“用户管理”这个权限塞进某个业务角色里,理由是“这个部门主管需要自己建账号”。这个口子一开,权限膨胀就开始了。
第三层是系统账号层。这一层压根不应该出现在应用层的用户列表里。系统账号是平台运行所需的凭据,它的口令变更应该走独立的运维通道,而不是挂在 Web 界面的某个菜单下。
三层混在一起的直接后果就是:一个业务层的普通账号,只要碰巧拿到了某个能触达改密接口的路径,就越过了两层边界直达系统层。
2.2 角色继承带来的权限膨胀
RBAC 本身没问题,出问题的是继承关系设计。举个常见的错误做法:把“只读角色”作为“操作员角色”的父角色,把“操作员角色”作为“部门管理员角色”的父角色,一层层往上继承。看起来结构很优雅,实际结果是每一个上层角色都自动拥有下层全部权限,而下层角色为了完成日常业务,往往被授予了远超其职责的权限。
更麻烦的是继承关系是传递的,改动一个底层角色的权限,会沿着继承链向上扩散,影响面难以评估。我在一个项目里见过一次事故:给“访客角色”补了一个“查看设备列表”的权限,结果因为继承链设计反了,这个权限一路传到了三个管理角色上,导致原本不该看到设备清单的岗位全都能看到了。
我的建议很直接:角色之间不做继承,只做组合。一个用户可以有多个角色,权限取并集,但角色与角色之间保持平行关系。这样每次权限变更的影响面就是“拥有该角色的用户集合”,是可以用一条查询语句算清楚的。
3. 校验位置决定成败:页面藏起来的按钮不代表服务端关了门
回到改密这件事。很多平台的权限控制是“三层皮”:菜单层藏、按钮层灰、接口层不管。前两层是给正常用户看的,第三层才是真正决定安全边界的地方。
3.1 前端隐藏、网关拦截、服务层校验的分工
一个健康的校验链路,应该有三个彼此独立的关口,任何一关失守都不至于直接暴露。
| 关口 | 职责 | 失守后果 | 是否可作为唯一防线 |
|---|---|---|---|
| 前端渲染 | 按权限码决定菜单与按钮可见性 | 用户误操作概率上升 | 否 |
| 网关/拦截器 | 统一做身份认证与粗粒度权限过滤 | 未授权请求进入业务层 | 否 |
| 服务层方法级 | 按具体操作做细粒度权限判断 | 越权操作被执行 | 是 |
前端那一层的作用是体验,不是安全。把它当安全防线是最常见的认知错误。网关层能做粗粒度过滤,但它通常只知道“这个接口属于哪个模块”,不知道“这个用户对这条具体数据有没有操作权”。真正靠得住的只有服务层的方法级校验,因为它离数据最近,能看到完整的上下文。
3.2 一个可复用的服务端拦截器写法
方法级校验落地时,最省事的做法是用注解加切面,把权限判断从业务代码里剥离出来。下面这段是通用思路,语言上以 Java 生态为例,换成其他技术栈逻辑完全一致。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String code(); // 权限码,如 "system.password.reset" boolean audit() default true; // 是否记入审计 }@Aspect @Component public class PermissionAspect { @Around("@annotation(requirePermission)") public Object check(ProceedingJoinPoint pjp, RequirePermission requirePermission) throws Throwable { String code = requirePermission.code(); String userId = CurrentContext.getUserId(); // 关键点一:先判空,未登录直接拒绝,不走后续逻辑 if (userId == null) { throw new PermissionDeniedException("未认证"); } // 关键点二:权限判定必须查实时权限,不能用会话里缓存的旧快照 if (!PermissionService.hasPermission(userId, code)) { AuditLogger.deny(userId, code, pjp.getSignature().toShortString()); throw new PermissionDeniedException("无权限:" + code); } // 关键点三:高危操作单独记审计,成功失败都要记 if (requirePermission.audit()) { AuditLogger.before(userId, code); } try { Object result = pjp.proceed(); if (requirePermission.audit()) { AuditLogger.after(userId, code, "success"); } return result; } catch (Throwable t) { if (requirePermission.audit()) { AuditLogger.after(userId, code, "fail"); } throw t; } } }这段代码里有三个容易做错的地方,值得单独说。
第一,未认证的分支必须前置。我见过反过来的写法,先查权限再判登录状态,结果权限查询函数在 userId 为空时返回了 true(因为查不到记录就默认放行),直接导致未登录用户可调。
第二,权限判定不能用缓存快照。有些平台为了性能,把用户的权限集合在登录时算好塞进会话,之后一直用这份快照。问题是管理员收回权限之后,旧会话里的快照还在,权限回收要等到用户重新登录才生效。高危操作的权限判定必须实时查库。
第三,审计要记在切面里而不是业务代码里。业务代码里记审计,总会有几个分支忘了加,而切面是天然覆盖全路径的。
4. 改密这条链路,每一环都可能被“借用”
权限校验补上之后,还要看改密链路本身。这条链路上有三个环节,任何一个设计不当,都会让权限校验形同虚设。
4.1 应用账号与操作系统账号的边界模糊问题
最典型的坑是:平台为了“方便运维”,把修改操作系统账户口令的能力包装成了一个 Web 功能,并且在实现上直接调用了系统命令或者写入了系统配置文件。这个设计一旦存在,Web 层的权限校验就成了唯一防线,而 Web 层恰恰是最容易被绕过的。
正确的做法是把这条能力彻底隔离出去。平台不应该具备直接修改操作系统账户口令的能力,这类操作应该走独立的运维流程,由具备相应权限的人员在受控通道内完成。如果业务上确实需要“平台内改密”,那它改的应该是平台自己的应用账户,而不是操作系统账户,两者在存储位置、加密方式、生效机制上完全不同。
我整理过一份对照,把两类账户的区别列清楚,团队评审时对着看能省很多争论。
| 维度 | 应用账户 | 操作系统账户 |
|---|---|---|
| 存储位置 | 应用数据库 | 系统账户文件 |
| 加密方式 | 加盐哈希(bcrypt/Argon2) | 系统口令哈希 |
| 生效机制 | 下次登录校验 | 立即生效 |
| 变更通道 | 应用接口 | 运维通道 |
| 审计归属 | 应用审计日志 | 系统审计日志 |
这两类账户一旦在实现上打通,应用层的所有权限设计都会被架空,因为攻击面从“应用权限”直接下沉到了“系统权限”。这是架构层面的问题,靠打补丁解决不了,只能在设计阶段就划清。
4.2 密码在传输与落盘过程中的处理方式
改密功能的实现细节里,有几个高频错误值得单独列出来。
明文口令进日志。这是最普遍也最要命的一个。很多平台的请求日志会把整个请求体打出来,改密请求的请求体里就是新旧口令的明文。日志一旦落到文件系统或者被采集到日志平台,等于口令长期裸奔。处理方式很简单:在日志框架里对敏感字段做脱敏,字段名匹配password、passwd、pwd、secret、token的一律替换成***,并且这条规则要放在最外层的日志配置里,不能指望每个业务模块自觉。
哈希算法选型。还在用 MD5 或者单轮 SHA-256 存口令的系统不在少数。这两类算法的计算速度太快,在现代硬件上做暴力枚举的成本极低。口令哈希应该选专门为慢速验证设计的算法。
| 算法 | 是否推荐 | 说明 |
|---|---|---|
| MD5 / SHA-1 | 不推荐 | 计算过快,且已有大量彩虹表 |
| SHA-256 单轮 | 不推荐 | 速度快,抗暴力能力弱 |
| PBKDF2 | 可用 | 迭代次数需配置到 10 万次以上 |
| bcrypt | 推荐 | 自带盐值,成本因子可调 |
| Argon2id | 推荐 | 抗 GPU 与侧信道表现更好 |
改密后的会话处理。口令改完之后,旧会话必须失效。这一点经常被忽略:用户改了密码,但之前在其他设备上登录的会话还活着,等于改密没起到“踢掉异常会话”的作用。实现上可以在用户表里加一个credential_version字段,改密时自增,会话校验时比对版本号,不一致就强制重新登录。
初始口令与重置口令的传递。通过邮件或短信发初始口令的做法,安全性取决于通道本身。更稳妥的做法是发一次性重置链接,链接带有效期和一次性令牌,用户点进去自己设定新口令,口令全程不经过第三方通道。
5. 自查清单:用最小权限账号把功能走一遍
权限设计做完了不等于生效了。上线前必须做一轮系统性的自查,而这轮自查最有价值的方法,是用一个权限最小的账号,把平台所有功能入口走一遍。
5.1 权限矩阵的建立方法与核对节奏
先建一张权限矩阵表,横轴是角色,纵轴是操作,交叉格填“允许/禁止”。这张表不是给开发看的文档,而是自查时的对照清单。
具体做法我通常是这么几步:
- 从数据库里把全部权限码导出来,按模块分组,去掉已废弃的,形成操作清单。
- 把全部角色导出来,标注每个角色对应的岗位和实际职责。
- 逐格填写预期值,遇到“说不清该不该给”的格子,一律填“禁止”,让它成为默认拒绝。
- 用一个只有单一角色的测试账号登录,对纵轴上的每个操作实际点一遍,记录“能否完成”。
- 把第 4 步的实际结果和第 3 步的预期值做比对,差异项就是需要修的地方。
第 3 步的“默认拒绝”原则很重要。权限设计的默认值必须是拒绝,需要额外授予才放开。反过来做——默认放开、需要时再收紧——在实践中几乎必然漏。
这张矩阵表还有个额外好处:它能暴露出角色设计的冗余。当你发现两个角色的权限格几乎完全一样时,说明角色划分有问题,要么合并,要么职责边界没想清楚。
5.2 用审计日志做反向验证
正向走查只能覆盖你想到的路径,反向验证能覆盖你想不到的。方法是:在测试环境里用最小权限账号做一轮完整操作,然后把这一时段的审计日志全部拉出来,看有没有记录到不该出现的操作类型。
审计日志要至少包含这几个字段才能支撑这种验证:操作时间、操作账号、操作类型(对应权限码)、操作对象、结果(成功/失败)、来源地址。缺了“操作类型”这一项,日志就只能看“谁在什么时候登录了”,没法回答“谁在什么时候干了什么”。
我在一次自查里就是这么发现问题的:走查时所有按钮都是灰的,看起来没问题,但审计日志里出现了三条“配置修改”类型的成功记录,来源是那个测试账号。追下去发现是某个页面的保存按钮被隐藏了,但页面加载时会自动触发一次保存请求,这个请求没带角色判断。这种问题靠点按钮是永远发现不了的。
5.3 自动化回归:把高危操作做成探针
手工走查只能做一次,代码一改就失效。把高危操作的校验做成自动化用例,每次发版跑一遍,成本低效果好。
具体做法是把“高危操作清单”转化成一组接口级的测试用例:用低权限账号的凭据去调用,断言返回的是权限拒绝。清单里至少要包含这几类:
- 账户创建、删除、角色变更
- 口令重置、口令修改
- 系统参数修改、配置导入导出
- 日志清理、审计开关变更
- 数据导出(尤其是全量导出)
这些用例不需要验证业务逻辑正确性,只验证“该拒绝的拒绝了”。它们跑起来很快,适合挂在持续集成的流程里,作为发版前的硬性检查。
注意:这类自动化用例的凭据管理要单独处理。测试账号的口令不能写死在代码仓库里,应该从配置中心或者环境变量注入,并且这个账号的权限要在测试环境里配成最小集,绝不能给它任何管理权限。
6. 加固落地:把拦截点放在离数据最近的地方
前面讲的是“发现问题”,这一节讲“怎么补”。核心思路一句话:拦截点越靠近数据,越难被绕过。
6.1 高危操作的二次认证与双人复核
对于口令重置、角色变更、系统配置修改这类操作,光有权限校验还不够,应该再加一道二次认证。用户在点击操作后需要重新输入自己的口令,或者输入动态验证码,通过后才执行。
二次认证的价值在于,它把“会话被劫持”这个场景挡住了。即使攻击者拿到了有效会话,没有用户本人的口令或验证码,仍然做不了高危操作。
再往上走一级是双人复核。操作发起后进入待审批状态,需要另一个具备审批权限的人确认才生效。这个机制适合用在“不可逆”的操作上,比如删除账户、清空审计日志。实现上要注意审批人不能是发起人本人,这个判断要在服务端做,不能靠界面限制。
6.2 账户生命周期与密码策略
账户本身就是攻击面,管好账户比管好密码更重要。几个必须落地的点:
离职即禁用。账户禁用应该由人事流程自动触发,而不是等管理员手工操作。中间的时间差就是风险窗口。如果做不到自动触发,至少要做到定期比对,比如每周导出一次在职名单和账户名单做交集比对。
长期未登录账户处理。超过 90 天未登录的账户自动锁定,需要管理员解锁才能恢复。这一条能有效压缩“僵尸账户”的攻击面。
口令策略要合理。强行要求 90 天改一次口令、必须包含大小写数字符号的这类策略,实际效果往往适得其反,用户会把口令写在便签上贴在显示器边上。更合理的做法是:长度优先于复杂度(至少 12 位),不强制定期更换(除非发生泄露迹象),但要做已泄露口令比对——新设口令不能出现在常见弱口令库和历史泄露口令集合里。
权限回收要有节奏。岗位调整之后,旧岗位的权限必须收回。这件事靠人记必然漏,做法是在账户表上记录“权限授予时间”,定期生成“授予超过一年且从未使用过的权限”报表,由权限所有者确认是否保留。
6.3 日志留存与告警阈值
审计日志的价值在于事后追溯,但更在于事中告警。几个值得配置的告警规则:
| 告警场景 | 触发条件 | 处理动作 |
|---|---|---|
| 权限拒绝突增 | 同一账号 5 分钟内拒绝次数 > 10 | 临时锁定并通知 |
| 高危操作集中 | 同一账号 1 小时内高危操作 > 3 次 | 通知安全负责人 |
| 非工作时间操作 | 高危操作发生在非排班时段 | 记录并复核 |
| 权限变更 | 任何角色权限变更 | 通知权限所有者 |
日志留存周期上,安全相关的审计日志建议至少保留 180 天,并且要保证日志本身不可被应用层删除。我见过的一个设计是日志写入独立存储,应用只有写权限没有删权限,清理动作由单独的运维流程完成。这个设计能防止“先改密再清日志”这种操作。
7. 几个不太容易想到的细节
最后把几个零散但很实用的经验放在这里,都是从实际问题里攒出来的。
接口文档要同步标权限码。接口文档里只写参数和返回值,不写“调用该接口需要什么权限”,是权限遗漏的重灾区。规范做法是在文档的每个接口下面标注权限码,且这个标注要和代码里的注解保持一致,最好能通过工具自动校验一致性。
默认返回值要检查。很多权限判断函数在“查不到权限记录”时返回 true(默许)或者返回 null(上层判空写错就变成放行)。这类默认值要统一改成返回 false,并且加单元测试锁死这个行为。
批量接口是校验的盲区。单条操作的接口往往加了校验,批量操作接口因为实现方式不同,容易漏掉。批量接口的权限校验应该是“全部通过才执行”,而不是“逐条判断、跳过无权的”,后者会出现部分成功部分失败,且失败的部分可能被忽略。
导入功能要单独测。配置导入、用户批量导入这类功能,往往会绕过正常的创建流程直接写库,权限校验和字段校验都容易缺失。测试时要用低权限账号尝试导入一个包含高权限账户的文件,看能不能成功。
会话过期时间分场景设置。管理面的会话过期时间应该短于业务面,高危操作前的二次认证有效期应该更短,建议控制在 5 分钟以内。
改密接口要有频率限制。同一账号对同一目标的口令修改,应该限制频率,比如 10 分钟内不超过 3 次,防止被用来做口令枚举。
把上面这些串起来看,其实是一件事:权限校验不是某一个功能,而是一条贯穿账户体系、接口设计、数据访问、日志审计的链路。任何一环只做表面功夫,整条链路就是断的。我个人的体会是,做这类平台的安全加固,与其事后一项项补,不如在架构设计阶段就把“默认拒绝、显式授予、就近拦截、全程留痕”这四条写进设计规范里,后面所有功能都照着这四条走,能省掉大量的返工。