上一篇说了为什么要做后台。这一篇讲一个更不浪漫的话题:这个后台是直接暴露在公网上的 ,能封人、能删帖、能改参数,要是被人摸进去,后果比业务接口出问题严重得多。所以它在设计上,就把安全放在了前面。
先交代一个前提:下面没有什么高深的方案,每一条都很朴素。但「朴素地全都做到」,已经能挡住绝大多数针对这类小站点的扫描和撞库。
一个入口,一道关
后端里所有管理接口都在 /admin/ 下面,只有一个过滤器 AdminAuthFilter 在管它。规则很简单:
除了登录接口,其他任何 /admin/** 的请求,必须带上合法的身份,否则直接 401。
这里有个我特别看重的约定,写在了类的注释里:所有管理控制器不得再自行校验 token,身份统一从过滤器拿。 为什么?因为「每个接口自己检查」这种事,十个里有九个你会写对,剩下那一个你忘了,那就是一个裸奔的后台接口。把鉴权收口到一个地方,漏掉的可能性就从「每个接口都可能漏」变成了「只有这一处可能出错」。
管理端和用户端,会话要分开
小程序的用户登录用的是 Sa-Token,登录态是微信 openid。如果管理员会话也混在同一套体系里,就会有各种让人不安的可能:用户的 token 能不能碰到管理端?两边的有效期、过期策略怎么区分?
所以管理端用了一个独立的登录体系 (StpLogic("admin")),会话有效期单独配置(默认 12 小时)。这里有个小坑,代码注释里记着:如果直接把管理端的 StpLogic 注册成一个 Spring Bean,Sa-Token 会把它当成默认的账号体系 ,反而把用户端顶掉。所以是通过一个 Holder 包装一下再注入的。听起来很绕,但教训很清楚:用一个框架的「多账号体系」功能时,要先看清它怎么发现和选择体系。
令牌放在请求头,不放 Cookie
管理端会话的 token 放在 X-Admin-Token 这个请求头里,不用 Cookie。原因有一个很实际的好处:天然免疫 CSRF。 CSRF 攻击的原理是诱导用户浏览器「自动带着 Cookie 发请求」。如果身份根本不在 Cookie 里,而是要前端代码主动放进请求头,攻击者的那个恶意页面就没法替用户带上它。
代价是前端要自己管理 token。这里是存在浏览器的 localStorage 里,每次请求由 axios 的拦截器放进请求头,遇到 401 就清掉并跳回登录页。
这里要老实说一个取舍:localStorage 里的东西,页面上任何一段脚本都读得到。 所以不用 Cookie 躲开了 CSRF,却把风险转到了 XSS 上——一旦有人能往后台页面里注入脚本,token 就可能被读走。这正是后面那条严格的 CSP(只允许同源脚本)特别重要的原因:它和「token 放请求头」是配套的,缺一个,另一个的好处就打折扣。
登录:三道闸
登录接口是整个后台最容易被打的地方,所以设了三层:
第一层,按 IP 限制「请求本身」。 每个 IP 在一分钟内最多尝试 10 次登录,超了直接返回 429。注意这一层拦的是「请求」,而不是「失败」。注释里解释了为什么:这能挡住撞库和密码喷洒,也顺带挡住有人拿登录接口当 BCrypt 的计算耗时接口,做资源耗尽攻击 。BCrypt 故意很慢,慢就是它的防护,但慢也意味着每次校验都吃 CPU,被人批量打就是一个现成的 DoS 入口。
第二层,失败计数加锁定。 同一个账号失败 5 次,或者同一个 IP 失败 20 次,就锁 15 分钟。用 Redis 计数,登录成功则清除账号的失败计数。账号和 IP 两个维度都要,缺一个都有漏洞:只按账号限,攻击者可以对一堆账号各试几次;只按 IP 限,攻击者可以换 IP 打同一个账号。
第三层,BCrypt。 密码用 BCrypt 存,不存明文,也不用 MD5/SHA 这类快速哈希。
还有一个很小的细节:登录失败时,不管是「用户不存在」还是「密码错误」,返回的提示都是同一句「用户名或密码错误」,不暴露账号是否存在。
比较令牌要用「恒时比较」
后台还给脚本留了一个静态服务令牌 的通道(方便 curl 和自动化脚本调用)。这个通道有几个设计:
- 默认关闭。 配置里令牌留空,这个通道就不存在。只有明确配置了才打开;
- 比较用恒时比较 (
MessageDigest.isEqual),而不是普通的字符串equals。普通比较一旦遇到第一个不同的字符就提前返回,攻击者理论上可以通过响应时间的细微差异,逐字节猜出令牌。恒时比较不管对错,耗时都一样; - 启动时检查强度 :令牌长度不到 24 位,日志里直接告警,建议用 32 位以上的随机值,或者干脆关掉这个通道。
建议是:除非真的有脚本需要它,否则就让它关着。
响应头:当后台是一个「不愿被看见的页面」
后台页面和管理 API 的响应都会加上一组安全头:
Content-Security-Policy:脚本只允许同源加载,构建产物里没有内联脚本;X-Frame-Options: DENY和frame-ancestors 'none':不允许被嵌进别人的页面,防点击劫持;X-Content-Type-Options: nosniff、Referrer-Policy: same-origin;X-Robots-Tag: noindex, nofollow:别让搜索引擎收录;Permissions-Policy:关掉定位、麦克风、摄像头权限。
CSP 里有一个小细节:后台要显示 MinIO 里的图片,而图片所在的是另一个端口,浏览器算作不同源,所以 img-src 要单独放行。这种东西只有真的在浏览器里把页面打开、看控制台报错才会发现。
日志别被扫描器刷屏
公网上的服务,每天都会被各种扫描器敲门。如果每一次鉴权失败都打一条 WARN,日志很快就淹没了真正需要关注的事。所以鉴权失败的日志做了节流 :同一个「方法 + 路径」,30 秒内只打一次 WARN,其余降级成 DEBUG。
审计:谁在什么时候对什么做了什么
后台里所有变更类 操作,包括登录成功、登录失败、被锁定、回复反馈、删帖、封号、改参数,都会写进 admin_audit_log:操作者、动作、操作对象、IP、User-Agent。身份要么是账号名,要么是 service-token(脚本调用)。
它有两个用处。第一,出事之后能查;第二,你会知道谁在试探你 ——login_failed 和 login_locked 这两类记录,本身就是一份入侵尝试的日志。
第一次启动怎么建管理员
最后一个经常被忽略的问题:第一个管理员账号怎么来?常见的做法是首启引导 ,这里也是这样做的:配置里写一个初始账号和初始密码,系统启动时如果发现管理员表是空的,就自动创建一个,之后(表里有数据了)这两项配置就不再生效。
流程的最后一步很重要:登录后立刻改密码,并且把配置文件里的init- 那两行删掉。 初始密码是写在配置里的,不删就等于一份明文凭据长期躺在文件里。
最后说一句
这些措施没有一条是新鲜的,它们都是「该做的」。如果你的后台还没有做到,可以按这个顺序补:统一鉴权入口 → 登录限流 → 密码哈希 → 审计日志 → 安全头 。前三条能挡住绝大多数攻击,后两条让你出事之后不至于一头雾水。