Spring Boot + MyBatis Plus 管理端登录模块:从表设计到安全加固

发布时间:2026/10/24 3:24:35
Spring Boot + MyBatis Plus 管理端登录模块:从表设计到安全加固

接手“尚筹网”管理端开发之后,我碰到的第一个硬骨头就是管理员登录。这是系列里的第二篇,第一篇已经把 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> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency>

为什么不用 MD5?MD5 加盐虽然比纯 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 = "Admin@800820"; 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 Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> failure(String message) { Result<T> 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 Result<Void> 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 Result<Void> 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 lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>尚筹网管理后台登录</title> <link rel="stylesheet" th:href="@{/admin/static/css/login.css}"> <script th:src="@{/admin/static/js/jquery-3.6.0.min.js}"></script> </head> <body> <div class="login-box"> <h2>尚筹网管理后台</h2> <form id="loginForm"> <div class="form-item"> <label for="loginAcct">账号</label> <input type="text" id="loginAcct" placeholder="请输入登录账号"> </div> <div class="form-item"> <label for="userPswd">密码</label> <input type="password" id="userPswd" placeholder="请输入登录密码"> </div> <div class="form-item"> <button type="button" id="loginBtn">登 录</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 工具把典型场景全部过一遍,登录这种核心模块尤其要测试完整。

场景入参预期结果
正常登录正确的账号、正确的密码返回 code=200,客户端跳转首页
密码错误正确的账号、错误的密码返回 code=500,提示“账号或密码错误”
账号不存在不存在的账号、任意密码返回 code=500,提示“账号或密码错误”
参数缺失只有账号没有密码返回 code=500,提示“账号和密码都不能为空”
未登录访问管理页无 Session302 跳转到登录页

我在项目里还专门验证了“密码包含特殊字符”的情况。因为 BCrypt 生成的哈希串里有$符号,如果matches()方法的参数顺序写反,或者把哈希串拼进了日志,可能控制台会打出完整密码哈希。于是我在测试时顺手检查了日志,确保没有任何地方打印了userPswd字段。

5.2 防暴力破解:连续失败锁定机制

没有防暴力破解的登录接口,等于给攻击者开了一扇慢慢试密码的门。第一版我没有引入验证码,因为验证码依赖额外的前端插件和图片生成组件,但我至少实现了“连续失败锁定”。

思路很简单:用内存里的Map<String, FailRecord>记录每个账号的失败次数和时间。连续 5 次失败后锁定 5 分钟,锁定期间即使密码正确也不放行。

@Component public class LoginFailGuard { private static class FailRecord { private int count; private long firstFailTime; } private final Map<String, 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 读取都做一遍类型判断。

写到这里,“尚筹网”的管理员登录模块就从需求、建表、接口、前端、测试到加固完整走了一遍。最后再分享一条个人体会:登录功能做多了之后,发现真正让一个系统变可靠的,不是依赖某个框架的魔法,而是把“密码不明文、接口有拦截、失败有限制、退出有销毁”这些纪律老老实实执行到位。回到我这个项目,当前版本已经可以支撑管理后台的正常使用,下一步我会在这个基础上继续做管理员的列表展示与权限角色拆分——那是比登录更有意思的模块。