
接手“尚筹网”管理端开发之后我碰到的第一个硬骨头就是管理员登录。这是系列里的第二篇第一篇已经把 Spring Boot MyBatis Plus 的工程骨架搭好了。登录功能乍看就是“账号密码查表比对”如果只做到这个程度离一个能上线、能扛住常规渗透测试的管理后台还差得远。我这一篇就把从表设计、接口实现、前端联调到安全加固和踩坑复盘的全过程完整写出来。这个模块最适合两类人看一是刚学完 Spring Boot、想做一个真正能落地的完整登录功能的同学二是已经写过不少 CRUD、但对登录安全没什么体系概念的开发者。管理员登录和普通用户登录最大的区别在于权限边界——管后台的人一旦账号被拿走订单、用户、财务数据全都会被拖走。所以这篇文章我不会只给一套能跑的代码而是把每一步“为什么要这么做”也讲清楚。1. 登录模块的需求拆解安全边界先想清楚1.1 管理端登录与前台登录的差别“尚筹网”这种平台前台用户注册也好、登录也好权限范围也就是看项目、发起众筹、管理自己的账户。管理员不同看到的是全站数据、可以操作审核、可以调整订单状态。所以在设计登录时我不能照搬前台用户那套逻辑。管理端登录至少要覆盖这几件事账号密码校验、登录态保持、未登录拦截、异常情况提示。再往下拆还有密码在数据库里怎么存、登录失败怎么处理、Session 怎么防劫持、退出登录怎么销毁。这些不是写代码时才想的事而是动手建表之前就要定下来的。我身边很多同事的第一反应是“写一个 login 接口”但我更喜欢先列一份风险清单。因为登录是所有安全问题的集散地——攻破了登录后续的业务逻辑再好也挡不住。1.2 我们到底需要防住什么我把这个项目面对的威胁分成四类每一类对应一个落地方案威胁具体表现应对手段SQL 注入登录参数拼接进查询语句导致万能密码持久层用 MyBatis 的#{}预编译永远不用字符串拼接 SQL密码数据泄露数据库被拖走后明文密码直接暴露密码字段使用 BCrypt 加盐哈希存储明文不进数据库暴力破解攻击者用字典不断尝试账号密码连续失败次数限制 临时锁定 验证码会话劫持拿到 Session ID 后冒充管理员Session 绑定 IP 和 UserAgent并设置合理超时这四类问题里SQL 注入和明文密码是底线问题属于“出现了就是事故”暴力破解和会话劫持属于对抗性问题不一定会发生但必须做基础防护。聊天时我会说“能做多少做多少”但落到项目管理里这几条都是必须项。1.3 这一版技术选型是怎么定的技术栈我延续了第一篇的选型Spring Boot 2.7 MyBatis Plus MySQL 8 Thymeleaf。密码加密这块有一个取舍很多人一上来就引入 Spring Security但完整引入之后要面对过滤器链、安全配置、CSRF 开关一大堆东西学习成本立刻翻倍。我的方案是只引入spring-security-crypto这个精简依赖用它里面的BCryptPasswordEncoder来处理密码加密与比对。这样既拿到了业界认可的加密算法又没有把整个安全框架拖进来对教学项目来说刚刚好。dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency为什么不用 MD5MD5 加盐虽然比纯 MD5 好但现代 GPU 算力跑 MD5 已经到了“每秒几十亿次”的量级。BCrypt 不同它内部引入了慢哈希机制每次计算需要经过多轮迭代暴力破解成本成倍增加。而且它每次加密同一个明文产生的哈希串都不同自带随机盐这是 MD5 那一套没法比的。前端渲染我继续用 Thymeleaf 做服务端页面配合 jQuery 做表单交互。管理后台追求的是稳定、简单、好维护没必要在这个阶段引入 Vue 全家桶。2. 管理员表设计与初始化数据2.1 建表不能只考虑能存要考虑怎么查和怎么扩展登录表在设计时最容易犯的错是把登录账号和用户信息混在一个宽表里。管理员表其实很单纯它只需要管“谁能登录后台”所以字段做到精简反而好维护。CREATE TABLE t_admin ( id BIGINT UNSIGNED AUTO_INCREMENT COMMENT 主键, login_acct VARCHAR(100) NOT NULL COMMENT 登录账号, user_name VARCHAR(100) NOT NULL COMMENT 显示名称, user_pswd CHAR(60) NOT NULL COMMENT BCrypt加密后的密码, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_login_acct (login_acct) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT管理员表;三个细节我要单独解释。第一login_acct是唯一键。登录账号不允许重复这是业务底线。第二user_pswd用的是CHAR(60)而不是VARCHAR。BCrypt 的输出固定是 60 个字符用定长字段在 MySQL 底层存储上更省空间也暗示了这个字段只存放固定格式的哈希串。第三create_time和update_time都用数据库默认值管理应用层不需要手动维护减少出错点。2.2 初始管理员密码的正确生成方式表建好后不能直接往user_pswd里插明文比如insert ... values (admin, 123456)这种操作绝对禁止。正确做法是先用工具生成 BCrypt 哈希串再拿哈希串去初始化。我通常写一个临时 Java 类来生成用完就删import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class PasswordGenerator { public static void main(String[] args) { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); String rawPassword Admin800820; String encodedPassword encoder.encode(rawPassword); System.out.println(BCrypt encodedPassword); } }跑出来大概是$2a$10$XG8H...这种以$2a$10$开头的串。然后把输出结果粘贴到初始化 SQL 里INSERT INTO t_admin (login_acct, user_name, user_pswd) VALUES (admin, 平台管理员, $2a$10$XG8H...);这里我设计初始密码时没有用超级简单的123456而是设定了一个带大小写和特殊字符的临时密码。项目交付时管理员第一次登录应该强制改密但在教学项目里至少不能一上来就是弱口令。2.3 为什么 BCrypt 哈希串是同一明文的多个不同版本我刚开始学的时候有个困惑同一个密码每次用encoder.encode()生成的串都不一样那怎么比对这就是 BCrypt 的特性——它把随机盐直接拼在哈希串里。比对时用encoder.matches(rawPassword, encodedPassword)方法会从encodedPassword里把盐提取出来用同样的参数重新计算并比对。所以数据库里永远只需要存一个哈希串不需要额外存盐。这一点也和 MD5 加盐方案形成对比。老方案要在旁边单开一列存盐多一个字段多一份维护成本而 BCrypt 把这层复杂度封装掉了。这也是我在项目里坚持用它的原因。3. 后端登录接口的完整实现3.1 统一返回结构与业务异常登录接口不是只有“成功”和“失败”两种状态。参数为空、账号不存在、密码错误、服务端异常每种情况前端都要看到明确提示。所以第一步先做一个统一的返回结构。public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT failure(String message) { ResultT result new Result(); result.code 500; result.message message; return result; } // getter / setter 省略 }业务异常类也顺手定义好。我习惯让业务层直接抛出带提示信息的异常由全局异常处理器统一抓取并转成上面这个结构Controller 里就不需要到处写try-catch。public class BizException extends RuntimeException { public BizException(String message) { super(message); } }RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { return Result.failure(e.getMessage()); } }3.2 Controller 层的跳转与登录处理Controller 里我拆了两个入口。GET /admin/to/login负责渲染登录页POST /admin/do/login负责接收账号密码并完成校验。Controller RequestMapping(/admin) public class LoginController { Autowired private AdminService adminService; GetMapping(/to/login) public String loginPage() { return admin-login; } PostMapping(/do/login) ResponseBody public ResultVoid doLogin(RequestParam(loginAcct) String loginAcct, RequestParam(userPswd) String userPswd, HttpSession session) { if (StringUtils.isBlank(loginAcct) || StringUtils.isBlank(userPswd)) { throw new BizException(账号和密码都不能为空); } Admin admin adminService.getByLoginAcct(loginAcct); if (admin null) { throw new BizException(账号或密码错误); } BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); if (!encoder.matches(userPswd, admin.getUserPswd())) { throw new BizException(账号或密码错误); } admin.setUserPswd(); session.setAttribute(AdminConstants.LOGIN_ADMIN, admin); return Result.success(null); } }接口返回体用ResponseBody前端拿到 JSON 之后自己决定跳转。这里有两个细节要说明。第一账号不存在和密码错误返回的提示信息是同一个“账号或密码错误”。这样攻击者无法通过提示差异判断某个账号是否已经注册。第二查询出来并校验通过之后马上把admin对象里的密码字段清空再放进 Session。这是为了后续在任何地方读取登录信息时密码不流通到业务层之外。3.3 拦截器所有管理端请求都必须过这道门登录接口本身只能证明“登录这一刻你是对的”真正保证安全的是后续每一个请求都带着合法身份。Spring Boot 里实现这一点最直接的方式就是 HandlerInterceptor。public class AdminLoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object admin session.getAttribute(AdminConstants.LOGIN_ADMIN); if (admin null) { response.sendRedirect(request.getContextPath() /admin/to/login); return false; } return true; } }拦截器注册时需要把登录页、登录接口、静态资源放行其余/admin/**的请求全部拦下来。Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AdminLoginInterceptor()) .addPathPatterns(/admin/**) .excludePathPatterns( /admin/to/login, /admin/do/login, /admin/static/** ); } }这一段逻辑虽然只有十几行却是整个管理后台的安全地基。没有拦截器等于所有已实现的管理接口都裸奔在外面只要别人知道路径就能直接访问。3.4 Session 里放什么、放多大也要规划登录后我往 Session 里放的是admin对象也就是整条管理员记录。有的开发者图省事把整个 MyBatis 查询返回的实体直接塞进去这有两个隐患一是实体里可能带着不必要字段比如密码二是 Session 数据变大频繁序列化会拖累服务性能。我的做法很简单——既然t_admin表字段不多把密码清空后直接放实体是可以接受的。但如果将来管理员表越来越大、字段越来越多我会单独建一个LoginAdminVO只保留id、loginAcct、userName这三个字段往 Session 里放。还有一点容易被忽视Session 超时时间。Spring Boot 默认是 30 分钟在application.yml里可以显式声明server: servlet: session: timeout: 30m管理后台的 Session 超时不宜过长否则管理员离开工位后浏览器还保留着登录态存在被旁人操作的风险。30 分钟是一个比较合理的默认值。4. 前端登录页与交互实现4.1 登录页结构干净直接不要花哨管理后台登录页不像营销页需要视觉冲击力它只需要做一件事——让管理员快速完成输入和登录。所以我给“尚筹网”写的登录页非常朴素一个居中的卡片两个输入框一个提交按钮加上一个标题。!DOCTYPE html html langzh-CN xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title尚筹网管理后台登录/title link relstylesheet th:href{/admin/static/css/login.css} script th:src{/admin/static/js/jquery-3.6.0.min.js}/script /head body div classlogin-box h2尚筹网管理后台/h2 form idloginForm div classform-item label forloginAcct账号/label input typetext idloginAcct placeholder请输入登录账号 /div div classform-item label foruserPswd密码/label input typepassword iduserPswd placeholder请输入登录密码 /div div classform-item button typebutton idloginBtn登 录/button /div /form /div script th:src{/admin/static/js/login.js}/script /body /html样式用一个简单的手写 CSS 就够没必要为了登录页引入整套 UI 组件库。需要注意th:href和th:src生成的静态资源路径要和服务端放行的/admin/static/**保持一致否则拦截器会先把 CSS 和 JS 拦下来页面样式直接崩掉。4.2 表单校验与提交逻辑前端校验的定位是提升体验不是安全边界。真正的校验必须在后端再做一遍因为任何前端校验都能被绕过。我在login.js里做的事情有三件提交前做非空校验、请求期间禁用按钮防止重复提交、根据后端返回码执行跳转或错误提示。$(function () { $(#loginBtn).on(click, function () { var loginAcct $(#loginAcct).val().trim(); var userPswd $(#userPswd).val().trim(); if (!loginAcct || !userPswd) { alert(请输入账号和密码); return; } var $btn $(this); $btn.prop(disabled, true).text(登录中...); $.ajax({ url: /admin/do/login, type: POST, data: { loginAcct: loginAcct, userPswd: userPswd }, dataType: json, success: function (resp) { if (resp.code 200) { location.href /admin/main; } else { alert(resp.message); $btn.prop(disabled, false).text(登 录); } }, error: function () { alert(网络异常请稍后重试); $btn.prop(disabled, false).text(登 录); } }); }); });这里我踩过一个小坑搜索框的trim()一定要做因为有管理员手滑在账号前后带了空格后端loginAcct查不到记录又因为提示信息是统一的“账号或密码错误”当事人还摸不着头脑。后来我干脆在后端也做一次StringUtils.isBlank从前端到后端的双层处理都加上这种问题就绝迹了。4.3 错误提示的体验细节接口只返回code和message前端通过resp.message展示错误。这里有个产品层面的细节后端提示语要简短、明确、不能带技术术语。比如“系统内部错误请联系管理员”比“NullPointerException: /admin/do/login”适合得多。登录按钮在请求期间禁用的细节也值得写一下。如果没有这层处理用户双击按钮会发出两个一模一样的登录请求后端处理两次、Session 被覆盖虽然不影响最终结果但会给日志排查埋坑。加上 disable 之后这个隐患直接消失了。5. 联调测试与安全加固5.1 用一组用例把登录接口打透功能开发完不能直接打开浏览器点一下就说“好了”。我会在每个接口完成后用 HTTP 工具把典型场景全部过一遍登录这种核心模块尤其要测试完整。场景入参预期结果正常登录正确的账号、正确的密码返回 code200客户端跳转首页密码错误正确的账号、错误的密码返回 code500提示“账号或密码错误”账号不存在不存在的账号、任意密码返回 code500提示“账号或密码错误”参数缺失只有账号没有密码返回 code500提示“账号和密码都不能为空”未登录访问管理页无 Session302 跳转到登录页我在项目里还专门验证了“密码包含特殊字符”的情况。因为 BCrypt 生成的哈希串里有$符号如果matches()方法的参数顺序写反或者把哈希串拼进了日志可能控制台会打出完整密码哈希。于是我在测试时顺手检查了日志确保没有任何地方打印了userPswd字段。5.2 防暴力破解连续失败锁定机制没有防暴力破解的登录接口等于给攻击者开了一扇慢慢试密码的门。第一版我没有引入验证码因为验证码依赖额外的前端插件和图片生成组件但我至少实现了“连续失败锁定”。思路很简单用内存里的MapString, FailRecord记录每个账号的失败次数和时间。连续 5 次失败后锁定 5 分钟锁定期间即使密码正确也不放行。Component public class LoginFailGuard { private static class FailRecord { private int count; private long firstFailTime; } private final MapString, FailRecord failures new ConcurrentHashMap(); private static final int MAX_FAIL_COUNT 5; private static final long LOCK_DURATION_MS 5 * 60 * 1000L; public void recordFail(String loginAcct) { FailRecord record failures.computeIfAbsent(loginAcct, k - new FailRecord()); synchronized (record) { if (record.count 0) { record.firstFailTime System.currentTimeMillis(); } record.count; } } public boolean isLocked(String loginAcct) { FailRecord record failures.get(loginAcct); if (record null) { return false; } synchronized (record) { if (record.count MAX_FAIL_COUNT) { long waitMs System.currentTimeMillis() - record.firstFailTime; if (waitMs LOCK_DURATION_MS) { return true; } failures.remove(loginAcct); } } return false; } public void clear(String loginAcct) { failures.remove(loginAcct); } }在登录逻辑里账号密码校验之前先查isLocked校验失败之后调用recordFail校验成功之后调用clear。这套实现放在单机部署下足够将来如果改成多实例部署就得换成 Redis 之类的集中存储。我在做完这个功能后问了自己一个问题锁定 5 分钟会不会误伤管理员本人确实有可能。所以后续版本里我在登录接口里加了多维度判断比如同一 IP 触发多次锁定时的提示会更委婉。这个版本先用账号维度锁定代码最简单也足够覆盖大部分教学场景。5.3 会话指纹绑定给 Session 增加一道隐形门槛防会话劫持最简单的落地方式是把 Session 和客户端的 IP、UserAgent 绑定起来。首次登录时记录一个指纹后续拦截器里每次比对不一致就强制退回到登录页。public class SessionFingerprintUtil { public static String create(HttpServletRequest request) { String ip request.getRemoteAddr(); String userAgent request.getHeader(User-Agent); return md5(ip | userAgent); } private static String md5(String content) { // 使用 Commons Codec 或自己写一个 MD5 工具方法 return DigestUtils.md5Hex(content); } }登录成功时执行session.setAttribute(fp, SessionFingerprintUtil.create(request))。拦截器里比对前后两次指纹不一致就把 Session 作废并发回登录页。必须诚实说一句IP 可能变化比如公司网络出口切换UserAgent 也可能因为浏览器升级而变化。所以这个方案不是 100% 可靠它只能提高攻击门槛。对于管理后台来说多一层判断总比完全开放要强。6. 实际开发中踩过并处理掉的几个坑6.1 密码字段泄漏到响应体里联调过程中我用浏览器开发者工具观察了登录接口的返回发现data字段里竟然带着完整的userPswd哈希串。虽然 BCrypt 哈希不能由此反推出明文密码但它给了攻击者离线破解的素材——一旦拿到了哈希串就可以慢慢用字典跑。这个问题最直接的修法是在 Controller 里登录成功之后、返回之前把密码字段清空admin.setUserPswd();如果以后管理员表加了更多保密字段我会改用 VO只把id、loginAcct、userName复制到 VO彻底避免实体直接出现在接口返回值里。这里给各位一个建议任何接口的返回对象都当成面试题来做——明确列出来“哪些字段允许返回”而不是从实体里一刀切。6.2 静态资源被拦截器拦死之后登录页裸奔第一次启动项目看到登录页的时候HTML 结构出来了但 CSS 和 JS 全都 404。排查发现拦截器把/admin/static/**也拦了重定向又回到了登录页。这个坑最容易出现在“先写登录拦截器后写登录页面”的开发顺序里。解决方案我已经在前面提过注册拦截器时显式排除/admin/static/**。再补一句verifyCode之类的图片请求也要记得放行不然验证码永远加载不出来。6.3 退出登录接口没有放行Session 迟迟不销毁有的开发者会把退出登录做成/admin/logout结果强验登录的拦截器把这个路径也拦住了。表现是用户点了退出被重定向到登录页表面看没问题实际上后端还没来得及销毁 Session旧的登录态依然存在。处理办法放行/admin/logout并在这个接口里执行session.invalidate()。发布后用两个浏览器窗口验证过一次——A 窗口退出后B 窗口立刻访问管理首页必须重新登录才算真正退出成功。6.4 Session 里强转 Admin 类型差点翻车项目做过一次热重启之后有同事反馈“登录后偶发类型转换异常”。原因是应用重启后类加载器发生变化Session 里旧对象的 Class 和当前运行的 Admin Class 不是同一个强转直接抛ClassCastException。解决办法是在从 Session 取对象时先做instanceof判断不是预期类型就清掉 Session 并返回登录页。日志里打一行警告方便定位问题。Object attr session.getAttribute(AdminConstants.LOGIN_ADMIN); if (!(attr instanceof Admin)) { session.invalidate(); response.sendRedirect(request.getContextPath() /admin/to/login); return false; }这种问题在测试环境不容易冒出来往往是项目跑了一两天、经历过反复重启之后才出现排查起来特别容易让人怀疑人生。所以我在写这个模块的时候已经习惯性地把所有 Session 读取都做一遍类型判断。写到这里“尚筹网”的管理员登录模块就从需求、建表、接口、前端、测试到加固完整走了一遍。最后再分享一条个人体会登录功能做多了之后发现真正让一个系统变可靠的不是依赖某个框架的魔法而是把“密码不明文、接口有拦截、失败有限制、退出有销毁”这些纪律老老实实执行到位。回到我这个项目当前版本已经可以支撑管理后台的正常使用下一步我会在这个基础上继续做管理员的列表展示与权限角色拆分——那是比登录更有意思的模块。