多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

后台管理员登录系统设计指南:从认证原理到JWT+Redis实践

后台管理员登录系统设计指南:从认证原理到JWT+Redis实践 做了多年后台管理系统我越来越意识到一个事实后台管理员登录是所有内部系统的命门也是最容易被低估的一个模块。很多团队一开始觉得“登录不就是对一下用户名密码嘛”结果一到多环境部署、安全审计、权限拆分的时候就各种返工。这篇文章我想从“系统篇”的视角把后台管理员登录从架构设计、数据模型、核心流程到安全加固、生产排错完整拆一遍。我自己是在一个基于Spring Boot的中台项目里落地的这套方案踩了不少坑也总结出了一套可以直接抄作业的实践适合正在自研后台系统、或者想把登录模块系统化的开发者。1. 后台登录的真实复杂度为什么单独写一篇“系统篇”1.1 登录不是“一个接口”而是一横一纵两条链路如果你只把登录当成一个接口那确实没什么好写的。但当你把一个后台管理系统的登录模块拆开会发现它同时贯穿了两条链路横向链路从用户发起登录请求到参数校验、验证码校验、身份认证、状态记录、审计日志最后响应前端并建立会话。纵向链路从登录态生成、请求携带、后端鉴权、权限过滤到会话过期、续期、退出、被踢下线。任何一个环节设计不到位后续都会以 bug 或安全漏洞的形式还回来。我在第一个版本里就吃过亏当时只做了一个极简的登录接口用数据库字段存了一个随机token前端每次请求拿token来查管理员表。一开始只有20个内部用户一切正常。后来接入第三方运营人员用户量到了几百token校验变成了每次请求都查一次库慢不说而且token过期时间只能全局配置有人想改密码还不能立刻让旧token失效。这其实就是典型的“把登录当接口做”的后果——没有把登录当成一个子系统来设计。所以“系统篇”的第一个核心观点是登录功能需要有自己独立的模块边界、数据存储和生命周期管理而不是散落在业务代码里的几个方法。1.2 一个合格的登录系统要回答的七个问题在做任何一行代码之前我会先让团队把下面七个问题过一遍。这些问题看起来简单但大多数登录系统的返工都是因为前期没回答清楚凭证存什么登录名、手机号、邮箱还是都支持唯一性如何保证密码怎么存明文不讨论MD5、SHA、bcrypt还是加盐登录态是什么Session、Token、JWT还是组合状态存哪里服务端内存、Redis还是客户端存储过期和续期策略固定过期、滑动过期还是每次请求都刷新多端如何处理同账号允许多处登录吗互相踢还是共存安全底线是什么验证码、失败锁定、异地告警哪些必须有项目时间紧的时候这些问题很容易被跳过直接进入“写代码”阶段。但说实话这些问题里每一个的改动成本都会随着系统上线时间线性上升。上线以后再改登录方式涉及前端、网关、所有接口的鉴权逻辑工作量远超你的想象。我把这些问题在这篇文章里展开讲就是希望你在动手之前先在系统层面上想清楚。2. 技术选型与整体架构从Session到JWT Redis的演进2.1 为什么我没有直接用传统Session方案早期很多后台管理系统用的是Servlet容器自带的HttpSession登录成功以后把管理员ID塞进Session浏览器靠Cookie里的SessionID来维持状态。这套方案在小规模、单机部署下非常简单我最初也是这么干的。但到了实际生产环境它就暴露出几个很现实的问题集群部署时需要Session共享要么引入Spring Session Redis要么用Tomcat自带的集群Session复制后者在网络抖动时特别容易出问题。Session天然适合服务端渲染一旦后台前端是Vue或React这类SPA跨域请求处理Cookie要额外配置烦琐不说容错率也低。Session状态无法结构化携带如果你想在请求里直接读到当前管理员的角色列表、部门ID这类信息每次都要从Session再查一次库浪费性能。所以我最终选择了JWT Redis的组合JWT承担“认证凭证”的角色Redis承担“服务端可控状态”的角色。JWT无状态很适合水平扩展配合网关做鉴权也方便Redis则用来记录“哪些token依然有效”“失败的登录次数”“是否需要强制下线”等信息把无状态和有状态结合起来。2.2 请求从登录到鉴权的完整链路设计我把整个后台管理员登录与鉴权链路拆成六大环节每个环节只负责一件事。这个结构在后来的维护中给了我们很大便利环节职责关键组件登录入口接收账号密码、验证码完成身份认证LoginController、AuthenticationService凭证签发生成访问令牌与刷新令牌写入RedisTokenService请求入口网关或拦截器解析令牌校验有效性AuthInterceptor / GatewayFilter上下文装配从令牌中还原管理员信息注入当前请求上下文UserContext权限控制校验当前管理员是否有接口权限PermissionInterceptor会话管理处理登出、续期、互踢、强制下线SessionManager一次正常的请求链路是这样的管理员提交登录表单后端校验通过后生成一个JWT携带管理员ID和过期时间同时把token的hash存入Redis并设置过期时间。前端把JWT放在请求头里每次请求经过网关或拦截器时先解析JWT拿到管理员ID再去Redis查一下这个token是否还存在两者都通过才放行。这个设计听起来比“查库验证token”多了一步但Redis是纯内存操作性能损耗其实很低却能让我们随时控制token的生命周期。2.3 关于Spring Security和Shiro我的取舍建议后台管理系统做登录市面上绕不开Spring Security和Apache Shiro两个框架。我的观点是如果项目已经上了Spring Boot并且愿意花时间学习Security的过滤器链机制那就用Security它的OAuth2集成、方法级权限注解都非常成熟。如果只是给内部后台做一个轻量级登录团队里又有新人Shiro的上手成本更低配置更直观。如果是自研组件较多、需要高度定制登录流程的团队像我上面说的那样自己封装一个轻量认证模块反而比被框架牵着走更顺手。我自己这个项目最终没有直接用Security而是基于拦截器自研了一套轻量鉴权。选择原因很简单后台管理员数量有限、角色模型固定、不需要OAuth2社交登录Security的很多能力用不上反而为了绕过默认登录页还花了不少时间。当然如果你要做开放平台、对接第三方应用那直接上Spring Security OAuth2是更稳妥的选择。3. 数据模型设计管理员、角色、权限与凭证存储3.1 管理员表与角色权限表的ER设计登录系统的数据模型我建议至少包含四张核心表管理员表、角色表、权限表、以及管理员与角色的关联表。很多人图省事直接在管理员表里塞一个role字段比如“超级管理员”“运营”“财务”看起来简单但等你想给“运营A组”和“运营B组”配置不同权限的时候就只能改代码了。我的表结构关键字段如下-- 管理员表 CREATE TABLE sys_admin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, real_name VARCHAR(64), dept_id BIGINT, status TINYINT DEFAULT 1 COMMENT 1-启用 0-禁用, last_login_ip VARCHAR(64), last_login_time DATETIME, created_at DATETIME, updated_at DATETIME ); -- 角色表 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE, role_name VARCHAR(64), remark VARCHAR(255) ); -- 权限表 CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(64) NOT NULL UNIQUE, perm_name VARCHAR(64), type TINYINT COMMENT 1-菜单 2-按钮 3-接口, parent_id BIGINT DEFAULT 0 ); -- 管理员角色关联表 CREATE TABLE sys_admin_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, admin_id BIGINT NOT NULL, role_id BIGINT NOT NULL, UNIQUE KEY uk_admin_role (admin_id, role_id) );权限表里的type字段是我后来补的。最初只做了菜单权限结果上线后运营反馈“菜单能看到点进去却调不通接口”才意识到按钮级和接口级权限必须拆开。这个字段让权限表既能控制页面元素又能控制后端接口非常实用。3.2 密码存储的血泪教训从MD5到加盐bcrypt密码存储是我最想强调的一点。我刚入行的时候见过直接用MD5存密码的项目后来还见过把密码明文放在配置文件里的“内部系统”。这些做法在内部系统里也许能撑一阵子但一旦数据库泄露所有管理员的密码就等于裸奔了。我现在统一使用bcrypt算法原因有三点bcrypt自带随机盐相同的密码每次哈希结果都不同攻击者无法通过彩虹表反查。bcrypt的计算速度可以调整cost通常设为10~12单次哈希耗时约100ms用户无感知但攻击者暴力破解的成本会成倍增加。Spring Security和很多框架都有现成接口不需要自己实现加密算法。实践中我建议用专门的工具类封装public class PasswordUtil { // cost10太低不安全太高影响性能 private static final int COST 10; public static String encode(String rawPassword) { return BCrypt.gensalt(COST) : BCrypt.hashpw(rawPassword, BCrypt.gensalt(COST)); } public static boolean matches(String rawPassword, String encodedPassword) { String[] parts encodedPassword.split(:); return BCrypt.checkpw(rawPassword, parts[1]); } }这里我有一个真实案例系统上线三个月后有一次做等保测评安全工程师要求检查密码策略。我们当时只做了“密码强度校验”和“定期修改提醒”但明文存量密码的问题还是在迁移时才暴露出来。所以我的建议是从第一天就用bcrypt别等“以后再优化”。3.3 登录凭证表Refresh Token与可撤销性JWT本身是无状态的过期时间到了就失效但这也带来一个问题我们无法在服务端主动让某个token立刻失效。比如管理员改了密码、被禁用、或者被强制踢出旧的JWT在过期之前依然能用。解决方法是引入凭证表/凭证管理的概念。我不建议把整个JWT存进Redis因为JWT已经包含完整信息再存一遍属于空间浪费。我存的是JWT的唯一IDjti字段以及它对应的管理员ID、过期时间、设备信息。Redis的key可以设计成admin:token:{jti} - { adminId: 1001, expiredAt: 1710000000 }这样在鉴权时除了验证JWT签名再检查一下jti是否存在于Redis。如果管理员修改密码、被禁用我们就删除所有关联的jti实现“强制下线”。而Refresh Token则负责在Access Token过期后自动续签我通常只让前端保存Refresh Token并限制它的使用次数和有效期避免长期有效的凭证堆积成安全隐患。4. 登录核心流程实现从表单校验到会话建立4.1 登录接口的参数预处理与前置安全校验登录接口是所有接口里被攻击最多的一类所以参数校验绝对不能只依赖前端。我在后端登录接口中实现了四层前置校验第一层是基础参数校验username、password、captchaCode都不能为空格式要符合规范。第二层是验证码校验我先从Redis取出验证码key对应的值比对成功后立刻删除确保验证码一次性有效。第三层是账号状态校验如果管理员被禁用直接返回“账号已锁定请联系管理员”。第四层是登录频率校验同一个IP或同一个用户名如果短时间内失败次数超过阈值直接拒绝后续请求。这四层合起来大约几十行代码但能把大部分自动化攻击挡在门外。尤其是验证码一次性删除这个细节很多人会忽略导致验证码可以重复使用给撞库留下了可乘之机。4.2 认证成功后的令牌生成与Redis会话写入认证通过后核心逻辑就是三件事生成令牌、记录会话、更新登录信息。我用的是Java jjwt库示例代码如下public LoginResponse login(LoginRequest request) { // 1. 校验验证码 String captchaKey captcha: request.getCaptchaId(); String captchaValue redisTemplate.opsForValue().get(captchaKey); if (!request.getCaptchaCode().equalsIgnoreCase(captchaValue)) { throw new BusinessException(验证码错误); } redisTemplate.delete(captchaKey); // 2. 查询管理员并比对密码 Admin admin adminMapper.selectByUsername(request.getUsername()); if (admin null || !PasswordUtil.matches(request.getPassword(), admin.getPasswordHash())) { loginFailureHandler.record(request); throw new BusinessException(用户名或密码错误); } if (admin.getStatus() 0) { throw new BusinessException(账号已被禁用); } // 3. 生成JWTjti用于撤销 String jti UUID.randomUUID().toString(); Date now new Date(); Date expireAt new Date(now.getTime() accessTokenExpireMs); String token Jwts.builder() .setId(jti) .setSubject(admin.getId().toString()) .claim(username, admin.getUsername()) .setIssuedAt(now) .setExpiration(expireAt) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); // 4. 写入Redis设置过期时间 redisTemplate.opsForValue().set( admin:token: jti, admin.getId().toString(), accessTokenExpireMs, TimeUnit.MILLISECONDS ); // 5. 更新最后登录时间和IP adminMapper.updateLoginInfo(admin.getId(), request.getIp(), now); return new LoginResponse(token, refreshToken); }这段逻辑里我特别要说明两点。第一token的subject我用管理员ID而不是用户名因为用户名可能被改动ID是永远不变的。第二Redis的过期时间必须和JWT的过期时间保持一致否则会出现“JWT还能用Redis里已经查不到”或者反过来“Redis还有JWT已过期”的诡异问题。这个坑我在生产环境踩过一次后面讲排错会细说。4.3 鉴权拦截器与当前用户上下文登录态建立以后每个请求都要经过鉴权拦截器。我在拦截器里做的事情包括解析JWT、校验Redis中jti是否存在、刷新可选续期、以及把管理员信息放进ThreadLocal。这里我提一个容易忽略的点拦截器一定要在ControllerAdvice异常处理之前执行并且要捕获所有异常。否则一旦JWT解析失败异常直接被框架吞掉或返回的JSON格式不统一前端处理起来就非常痛苦。我会定义一个统一的结果结构鉴权失败时返回401 错误码 { code: 40101, message: 登录已过期 }前端拿到这个结构统一跳转登录页。ThreadLocal的使用也要特别注意一次请求处理完以后必须在线程return之前调用remove()否则在高并发线程池复用场景下会出现A管理员的信息串到B管理员请求里的严重事故。因为Tomcat的工作线程是复用的ThreadLocal不清理下一次请求就可能读到上一次的值。4.4 登出与被动下线的处理登出不只是“前端跳个登录页”那么简单。我要求后端必须提供一个登出接口前端在收到用户点击登出时调用后端拿到当前请求的jti删除对应Redis key。这样token立刻作废即使被截获也无法再次使用。被动下线强制踢出则有两种场景一种是管理员自己修改了密码需要把当前会话之外的所有会话踢下线另一种是超级管理员在后台禁用某个账号。这两种场景我通过调用SessionManager的revokeAll(adminId)方法根据“admin:token:*”的key模式查出该管理员所有会话并批量删除。这里要注意Redis的keys命令在生产环境要慎用数据量大时会导致阻塞。推荐用SCAN命令或者维护一个“admin:sessions:{adminId}”的set来记录全部jti这样踢人时可以直接遍历set删除。5. 安全防线验证码、失败锁定与登录审计5.1 图形验证码和滑块验证的实战选择验证码几乎是后台登录系统的标配但选哪种验证码里面很有讲究。我在最初版本用了纯数字的图形验证码后来发现两个问题一是自动化工具识别简单数字的成功率已经很高二是用户体验不好经常有人反馈看不清。后来我调研了市面上主流的方案主要有三类可选类型安全性用户体验接入成本纯数字/字母图形验证码低一般低数学运算图形验证码中一般低行为式滑块验证高好中最终我选择了“前置风险判断 数学运算图形验证码”的组合。具体来说用户在登录页第一次请求时不强制出验证码只有检测到连续失败次数达到2次或者当前IP在短时间内请求频率异常才在登录表单中出现验证码。这样做既保证了正常用户的登录体验又能在暴力破解场景下起到拦截作用。平时测试环境我总是开着验证码防止测试账号被扫到。5.2 连续失败锁定策略既要防爆破又不能误伤正常用户锁定策略是防止暴力破解的关键。我的实现是在登录失败时用Redis维护一个失败计数key比如login:fail:username:{username}每次失败自增1并设置过期时间为10分钟。当计数达到5次时该用户名锁定10分钟当计数达到10次时锁定30分钟。同时对IP维度也做同样的计数一旦某个IP的失败次数过高直接封禁该IP一段时间。这里有个经验值失败阈值的设置要考虑用户真实使用场景。比如公司内部系统管理员数量少、网络环境固定5次失败就锁定是合理的。但如果是面向外部运营商的系统误触发的概率就会上升建议把阈值提到10次并且锁定时间短一些比如5分钟。我们后来还接入了短信通知一旦有账号触发锁定就给安全管理员发一条告警消息这样比事后翻日志高效得多。5.3 登录审计日志的结构化设计与告警审计日志一开始我们没做结构化就是往数据库里记录一条字符串。等到想分析“谁在什么时候从哪个IP登录过”时才发现字符串没法聚合查询效率极低。后来我把登录日志单独设计成一张表字段如下CREATE TABLE admin_login_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, admin_id BIGINT, username VARCHAR(64), login_ip VARCHAR(64), user_agent VARCHAR(255), login_result TINYINT COMMENT 1-成功 0-失败 2-锁定, fail_reason VARCHAR(64), created_at DATETIME );这张表上线后我们做了三个查询场景每日登录成功/失败趋势、失败排名Top10的账号和IP、以及单账号的所有登录轨迹。配合定时任务每天扫描登录日志只要发现某个账号24小时内失败超过20次立即触发告警邮件加企业微信机器人一起通知。这个能力在等保测评时也是加分项。6. 多端登录、并发与高可用的细节处理6.1 同账号多端登录互踢还是共存后台管理系统对多端登录策略的需求往往被忽略。我见过有系统完全不限制同一个管理员账号可以在任意多的浏览器、手机上同时登录结果账号被离职员工继续使用也没人知道。也有系统过于激进同一个账号只要再次登录就把旧token全部踢掉结果管理员一边用电脑一边用手机两边来回被挤下线投诉不断。我的建议是采用“可配置的会话策略”。默认策略是同一个浏览器会话下互斥不同设备端可以共存。具体实现上管理员登录时允许传一个clientType参数WEB、APP、PADRedis的会话key设计成admin:token:{adminId}:{clientType}:{jti}踢人时只删除同一clientType下的旧会话。这样既保证了安全又不至于影响多设备使用体验。6.2 令牌续期机制滑动过期还是固定过期用过期的策略直接决定了后台用户“用着用着突然掉线”的概率。固定过期实现简单但体验不好滑动过期能避免用户频繁重新登录但也会让长期挂机账号永远不过期安全风险反而更大。我的方案是“固定过期 活跃续期”Access Token过期时间设为2小时。每次请求如果发现token剩余有效期小于30分钟就自动签发一个新的Access Token通过响应头X-New-Token返回给前端。前端封装请求拦截器发现响应头里有新token就自动更新本地存储用户全程无感。同时Redis里的续期次数会做限制一个会话最多续期7天7天后必须重新登录。这套机制上线后“被强制退出”的工单量下降了接近80%。但要注意续期逻辑必须只对“还在活跃使用”的会话生效不能给一个已经半天没活动的会话无限续期。我的做法是只允许在真实请求到达拦截器时触发续期而不是开一个定时任务统一续期后者会让所有会话越续越长失去意义。7. 生产环境排错时间戳、网关与缓存一致性7.1 集群部署后奇怪的“登录即掉线”问题系统第一次部署到多节点环境时出现了一个诡异的bug用户在本机单机环境调试一切正常部署到测试环境的2台服务器上就会出现“登录成功下一次请求就401”的现象。排查了很久最后发现是JWT的密钥导致的。我们的JWT密钥一开始是写死在每台服务器配置里的但发布时因为配置管理不规范两台服务器的密钥不一致。A节点签发的token被负载均衡转发到B节点B节点用自己配置的密钥去解析签名验证失败自然就401了。这个问题的根因不是JWT本身而是基础设施配置。解决方式很简单把JWT密钥统一放到配置中心或环境变量里确保每个节点一致。同时我建议在密钥配置中加入版本号将来要轮换密钥时旧token还能被旧版本key验证一段时间避免“一把密钥换下去所有用户全部掉线”的情况。7.2 JWT过期时间与Redis过期时间不一致这个坑我是真切踩过的。当时为了测试方便把Redis里的过期时间设置成了4小时而JWT里设置了2小时。结果到了快下班的时候发现系统偶发“请求失败”时好时坏。监控一看Redis里token明明还在但JWT已经过期了服务端返回401过了几分钟另外一个请求的JWT还没过期但Redis的key刚好到了4小时被清掉了同样401。两者不一致导致同一个用户在同一时间段不同请求出现不同的鉴权结果。后来我总结了一个规则Redis的TTL必须等于JWT的过期时间并且两者都从“签发时刻”开始计算。如果一定要延长Redis的保留时间那也是为了给审计日志留证据不能用来作为鉴权依据。最稳妥的做法是把过期时间统一配置在一个常量里JWT和Redis都以它为准从根本上杜绝“两边时间不一致”的问题。7.3 Nginx超时、网关透传与真实IP获取后台系统一旦上了Nginx反代和网关鉴权模块获取用户真实IP的方式就必须调整。我早期直接在Controller里用request.getRemoteAddr()获取IP结果所有登录日志里的IP都成了Nginx服务器的内网IP排查异常登录完全没法用。正确做法是在Nginx配置中把客户端IP写入X-Forwarded-For头然后在后端从该请求头里读取。但如果网关层不做清理攻击者可以伪造X-Forwarded-For头来绕过IP限流。所以我通常在网关这一层统一处理只信任由Nginx设置的最右侧一个IP覆盖原有的X-Forwarded-For内容这样到后端拿到的就是真实可用的客户端IP。登录审计、失败锁定、风控判断全部基于这个IP来做。7.4 Redis缓存一致性踢人失败与缓存穿透最后一个要提醒的是Redis和数据库之间的一致性。我们在踢人、禁用账号时逻辑是先更新数据库再删除Redis里的会话key。但这两个操作不是原子的如果数据库更新成功、删除Redis key失败就会出现用户已经被禁用却还能继续访问接口的严重问题。我采用的方案是“先删Redis再更新数据库再异步补偿删除一遍”。流程虽然多一步但能保证即使第一次删除失败最终也会通过补偿操作把残留的会话清掉。另外对于管理员基本信息的缓存我用了版本号机制每次更新数据库时version加1缓存key里带上version读不到就直接查库回填避免缓存和数据库不一致导致“账号改了角色权限却还是旧的”。一点实操心得把后台管理员登录当成一个系统来设计最大的好处是后续加需求、加安全策略、加审计报表时你不需要推翻重来。我自己在这套体系落地之后再接到“要支持验证码开关”“要增加第三方登录”“要对接SSO”之类的需求基本都是在既有架构上扩展模块而不是到处打补丁。最后分享一个小技巧在设计登录相关接口时尽量让所有错误码都从同一个枚举类里读取文案统一维护。登录模块的报错信息五花八门是客服和开发之间最常见的沟通成本来源一个稳定的错误码体系能帮你省下大量解释时间。
返回列表