
有段时间我帮朋友维护一个个人博客系统后台管理特别原始——发文章要直接连数据库往表里插用户想登录根本没有账号体系留言区就是个摆设。后来我实在看不下去用SpringBoot从零搭了一个JavaWeb博客系统把用户登录与注册放在最前面做。原因不复杂没有登录注册评论就挂不到人头上后台也没有边界任何访客都能看到你想留给自己的草稿。这篇文章是这次实践的一次完整复盘重点讲清楚注册怎么做、登录会话怎么保持、未登录用户怎么拦截以及我在实测里踩过的三个真实坑。如果你刚学SpringBoot想拿博客系统当练手项目或者在做其他JavaWeb管理系统这套思路可以直接抄。1. 开工之前的冷静思考博客系统为什么必须有登录注册1.1 四个躲不开的理由身份、权限、归属与安全先想清楚一件事HTTP协议本身是无状态的。浏览器请求一次页面服务器处理完就把这个请求忘了下一次请求跟上次之间没有天然关联。所以博客这种需要区分“谁是谁”的系统必须通过某种机制把每次请求和用户身份绑定起来。一个博客系统登录注册至少承担四个任务身份识别管理员登录后能进后台发文章普通用户登录后能评论、收藏系统得先知道当前操作者是谁。权限边界不是所有页面都对游客开放。文章发布页、用户管理页、设置页没有登录的人不该看见更不该允许操作。数据归属评论表、文章表里要记录作者和操作人。没有登录注册评论就只能是匿名文本想查“某个人发过哪些评论”完全做不到。安全底线后台操作必须有限制。哪怕是个人博客也不能让陌生访客改配置、删文章。需求确认以后功能边界就清楚了注册是允许外人成为系统用户登录是让已有用户重新进入会话退出是清掉会话状态。权限则通过会话状态在拦截器里判断。第一版不需要做太复杂但这条路要铺好。1.2 技术选型为什么我选SpringBoot MyBatis Thymeleaf动手前我在几套方案里对比过纯Servlet JSP能跑但配置繁琐数据访问代码要自己写一大包开发效率低。传统SpringMVC单体项目比Servlet先进但要配web.xml、配置类、打war包部署不够轻。SpringBoot自动装配、内嵌Tomcat、一个main方法就能启动生态成熟做JavaWeb博客系统很合适。我最终选的是SpringBoot 2.7 JDK8。有人觉得JDK8太老但很多毕业设计、公司老项目还在用兼容性稳教程也好找。数据层我用MyBatis理由很实际SQL可控尤其是后面要联表查用户、文章、评论时MyBatis写复杂查询更直观。模板引擎用Thymeleaf服务端渲染适合博客后台这种传统页面场景也比前后端分离的页面少处理一道跨域问题。1.3 会话方案选型Session还是JWT怎么选登录成功后服务器要记住“当前请求是谁”。最简单的就是Session用户信息存在服务器内存里浏览器端留一个JSessionID的Cookie。另一种是JWT把关键信息编码进一段签名过的token由客户端保存。对单体博客系统我偏向Session。理由有三点第一实现简单所有服务端渲染页面都能直接读Session第二注销好办调用invalidate()立刻让会话失效第三博客的并发量级大概率不需要做无状态集群。JWT更强的地方在前后端分离、微服务、多端共享登录态但单体项目里硬上JWT还要自己处理续期、黑名单、密钥管理得不偿失。维度Session方案JWT方案存储位置服务端内存或共享存储客户端保存token服务端扩展需要粘性会话或共享Redis天然无状态易横向扩展注销登录立即销毁session简单需要黑名单或短过期策略实现复杂度低容器帮你维护Cookie中需要处理签名、过期适合场景单体、服务端渲染前后端分离、分布式系统这套项目场景就是单体服务端渲染所以直接Session不用犹豫。2. 用户表设计登录注册的所有后续都从这里生长2.1 字段清单与设计理由用户表不用一开始设计得很重但几个核心字段必须有。我的表结构字段如下id主键自增Long类型。username登录用户名唯一加上唯一索引。password存储加密后的密码密文注意长度问题。nickname展示昵称允许为空为空时注册逻辑里默认用用户名。email邮箱唯一索引为后续找回密码、验证码预留。avatar头像地址存路径字符串不放二进制。role角色用字符串而不是布尔值因为以后可能不止管理员和普通用户两种角色。status账号状态1正常0禁用。第一版可能用不上但建议留否则以后想封禁账号只能删数据。create_time、update_time创建时间和更新时间几乎所有表都需要。username长度我设为30不是随便选的。太短用户取名不够用太长索引效率下降。邮箱设100足够存绝大多数地址。昵称和头像都允许空减少注册时的强制输入。2.2 密码字段长度暗藏的坑BCrypt与varchar(60)这段一定要单独说因为很多人都在这里翻车。密码字段最忌讳直接设成varchar(20)。BCrypt算法生成的密文固定是60个字符varchar(20)根本装不下。我之前在一个项目里把password设成varchar(32)结果注册时直接报Data too long for column password当时一脸懵查了半天才发现是密文比字段长。为什么不用MD5加盐MD5哈希速度太快攻击者可以用彩虹表、暴力穷举快速撞库。而且很多人的密码是弱密码不加盐的MD5在彩虹表里直接就能查出来。BCrypt的特点是计算速度适合做口令哈希内部自动带随机盐同样的密码每次加密结果都不同验证时用matches方法比对安全性好很多。所以建表时password字段最少是varchar(60)别省这30个字符。2.3 建表SQL与初始数据准备建表SQL我直接贴出来CREATE TABLE blog_user ( id BIGINT(20) NOT NULL AUTO_INCREMENT, username VARCHAR(30) NOT NULL COMMENT 登录用户名, password VARCHAR(60) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(30) DEFAULT NULL COMMENT 展示昵称, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, avatar VARCHAR(200) DEFAULT NULL COMMENT 头像地址, role VARCHAR(20) NOT NULL DEFAULT ROLE_USER COMMENT 角色, status TINYINT(4) NOT NULL DEFAULT 1 COMMENT 状态1正常 0禁用, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT博客用户表;管理员账号不建议在SQL里直接insert明文密码。正确做法是先写一小段Java代码调用BCryptPasswordEncoder生成一个哈希值再把这个哈希值手动插入数据库。这样即使有人拿到数据库备份也没法知道明文密码。邮箱字段我给它普通索引而不是唯一索引是因为第一版可能有人不填邮箱唯一索引会直接拒绝多个空值。3. 注册功能实现从表单提交到密码入库3.1 前端注册表单字段、校验与提示注册表单字段我控制在五个用户名、密码、确认密码、邮箱、昵称。昵称和邮箱可以选填但用户名、密码、确认密码必填。Thymeleaf模板里我推荐用th:object绑定到RegisterForm对象每个输入框用th:field对应字段这样出校验错误时用户填过的内容能回显不会因为一次错误全部清空。form th:action{/auth/doRegister} th:object${registerForm} methodpost label用户名/label input typetext th:field*{username} placeholder3-20个字符 / label密码/label input typepassword th:field*{password} placeholder6-32个字符 / label确认密码/label input typepassword th:field*{confirmPassword} placeholder再输入一次密码 / label邮箱选填/label input typetext th:field*{email} placeholder用于找回密码 / label昵称选填/label input typetext th:field*{nickname} placeholder默认显示用户名 / button typesubmit注册/button p classerror th:if${errorMsg} th:text${errorMsg}/p /form前端校验只有体验价值比如密码位数、两次密码是否一致可以用HTML5的required和JavaScript来做。但后端必须再完整校验一遍因为请求完全可以绕过页面直接发过来。3.2 Controller层接收请求与参数校验Controller这里有两个方法GET /auth/register负责渲染注册页POST /auth/doRegister负责接收提交。Controller RequestMapping(/auth) public class AuthController { private final UserService userService; public AuthController(UserService userService) { this.userService userService; } GetMapping(/register) public String registerPage(Model model) { if (!model.containsAttribute(registerForm)) { model.addAttribute(registerForm, new RegisterForm()); } return auth/register; } PostMapping(/doRegister) public String doRegister(Validated ModelAttribute(registerForm) RegisterForm form, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return auth/register; } try { userService.register(form); } catch (BizException e) { model.addAttribute(errorMsg, e.getMessage()); return auth/register; } return redirect:/auth/login?registered; } }这里的RegisterForm是一个专门接收表单参数的DTO类不是数据库实体。字段上直接加校验注解public class RegisterForm { NotBlank(message 用户名不能为空) Size(min 3, max 20, message 用户名长度需在3到20之间) private String username; NotBlank(message 密码不能为空) Size(min 6, max 32, message 密码长度需在6到32之间) private String password; NotBlank(message 确认密码不能为空) private String confirmPassword; Email(message 邮箱格式不正确) private String email; private String nickname; // getter / setter 省略 }注意Validated注解配上BindingResult是固定组合参数校验错误会直接进到后面的逻辑。另外我不建议在Controller里写业务判断比如“密码是否一致”“用户名是否存在”这些应该下沉到Service层Controller只做参数接收、错误处理和路由跳转。3.3 Service层核心逻辑唯一性检查、BCrypt加密、落库注册的核心在Service里。我先说一个关键决定密码加密只用spring-security-crypto这一个依赖没有引入完整Spring Security。完整的Security框架默认带过滤器链它会接管登录认证我自定义的登录逻辑还没执行就被它抢走了调试起来很痛苦。只引入crypto模块只借它的加密工具最干净。dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency然后注册一个PasswordEncoder的BeanConfiguration public class AppConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }Service代码Service public class UserService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper userMapper; this.passwordEncoder passwordEncoder; } public void register(RegisterForm form) { String username form.getUsername().trim(); String email form.getEmail() null ? : form.getEmail().trim(); if (userMapper.countByUsername(username) 0) { throw new BizException(用户名已被占用换一个试试); } if (StringUtils.hasText(email) userMapper.countByEmail(email) 0) { throw new BizException(该邮箱已经注册过了); } if (!form.getPassword().equals(form.getConfirmPassword())) { throw new BizException(两次输入的密码不一致); } BlogUser user new BlogUser(); user.setUsername(username); user.setPassword(passwordEncoder.encode(form.getPassword())); user.setNickname(StringUtils.hasText(form.getNickname()) ? form.getNickname().trim() : username); user.setEmail(email); user.setRole(ROLE_USER); user.setStatus(1); user.setCreateTime(new Date()); user.setUpdateTime(new Date()); userMapper.insert(user); } }几个细节username和email都做trim防止用户不小心前后空格导致“明明输入的是abc库里存的是‘ abc ’”下次登录死活对不上。业务异常用自定义BizException处理Controller统一展示message不用在每个接口写错误分发。密码一致性的判断放在Service不依赖前端。BCryptPasswordEncoder.encode()每次生成的密文都不同这是特性不是bug。验证时用matches(rawPassword, encodedPassword)。注册成功以后我用的是“POST后重定向到登录页”这是PRG模式Post/Redirect/Get。这样做的好处很明显用户注册成功后刷新登录页不会触发表单重复提交浏览器地址栏也变成登录页而不是停留在POST请求。这是页面开发里很值得养成的习惯。4. 登录功能实现从密码比对到会话写入4.1 登录表单与处理接口登录页相对简单。表单提交到 /auth/doLogin需要用户名和密码两个参数。GET /auth/login负责渲染登录页同时接收一个registered参数处理“注册成功”的提示。form th:action{/auth/doLogin} methodpost label用户名/label input typetext nameusername / label密码/label input typepassword namepassword / button typesubmit登录/button p classerror th:if${errorMsg} th:text${errorMsg}/p /formControllerGetMapping(/login) public String loginPage(RequestParam(value registered, required false) String registered, Model model) { if (StringUtils.hasText(registered)) { model.addAttribute(tipMsg, 注册成功请登录); } return auth/login; }4.2 认证逻辑统一提示“用户名或密码错误”登录认证放在Service里controller只负责取session和跳转。Service的认证方法public BlogUser authenticate(String username, String rawPassword) { String name username.trim(); BlogUser user userMapper.findByUsername(name); if (user null || !passwordEncoder.matches(rawPassword, user.getPassword())) { return null; } return user; }这里要注意错误提示统一用“用户名或密码错误”不要分别提示“用户名不存在”“密码错了”。区别背后的逻辑是防止用户名枚举如果系统对不存在的用户名和错误的密码返回不同信息攻击者就可以通过不断尝试判断出哪些用户名是真实注册过的。还有一个安全细节是时间差。用户不存在时直接跳过密码比较用户存在但密码错误时BCrypt的matches要额外算一次这两者的响应时间会有差异。对小型博客系统来说一般不用处理但如果你在做安全要求高的登录接口可以在用户不存在时也执行一次假的matches让响应时间接近避免被时序探测。登录的Controller写法PostMapping(/doLogin) public String doLogin(RequestParam String username, RequestParam String password, HttpSession session, RedirectAttributes ra) { BlogUser user userService.authenticate(username, password); if (user null) { ra.addFlashAttribute(errorMsg, 用户名或密码错误请重新输入); return redirect:/auth/login; } if (user.getStatus() ! 1) { ra.addFlashAttribute(errorMsg, 该账号已被禁用请联系管理员); return redirect:/auth/login; } session.setAttribute(Constants.SESSION_USER, user); return redirect:/admin/index; }状态为0的账号即使密码正确也不能登录。这个判断放在密码比对之后能给攻击者多一点信息但一般项目不需要过度隐藏提示“被禁用”更人性化。4.3 登录成功后的Session写入、页面联动与退出清理登录成功后把用户对象整体放入Sessionsession.setAttribute(Constants.SESSION_USER, user);Session有效时间我在配置里统一设置server: servlet: session: timeout: 30m服务端渲染页面可以直接判断Session里的loginUser来显示不同导航。Thymeleaf模板里这样写div th:if${session.loginUser} ! null span th:text${session.loginUser.nickname}/span a th:href{/logout}退出/a /div div th:unless${session.loginUser} ! null a th:href{/auth/login}登录/a a th:href{/auth/register}注册/a /div退出登录我强烈建议直接调用session.invalidate()而不是只移除loginUser属性GetMapping(/logout) public String logout(HttpSession session) { session.invalidate(); return redirect:/auth/login; }invalidate()会让整个Session失效旧的JSessionID也不能继续使用。这能降低Session固定攻击风险。攻击者如果先给用户一个已知的Session ID用户登录成功后这个Session如果没有重建攻击者就可能拿到登录态。主动销毁再重建会话是个穷人版的安全加固。5. 拦截器与权限边缘未登录用户不该看到的后台页面5.1 拦截器的判断逻辑与细节登录状态只写入Session还不够必须用到“需要保护”的路径上。这里我选择实现HandlerInterceptor。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); Object loginUser session null ? null : session.getAttribute(Constants.SESSION_USER); if (loginUser ! null) { return true; } response.sendRedirect(request.getContextPath() /auth/login); return false; } }注意我用了request.getSession(false)不是getSession()。getSession(false)表示“如果当前没有Session就返回null不要新建”。用getSession(true)会有个坏处每个未登录用户访问公开页面服务器都会创建一个空Session日志里Session数量暴涨响应头也会多出Set-Cookie。没必要的资源浪费。5.2 放行策略哪些路径必须放行拦截器注册到Spring MVC里通过配置类控制路径Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/admin/**, /user/center/**, /comment/add, /comment/delete/**) .excludePathPatterns( /auth/login, /auth/doLogin, /auth/register, /auth/doRegister, /css/**, /js/**, /images/**, /error ); } }我特意没有拦截/**而是只拦截后台和操作型路径。博客首页、文章详情页、文章列表页都应该对外开放否则搜索引擎都进不来。这里路径设计上有个经验把“读取评论列表”和“提交评论”分开路径列表路径不拦截提交路径拦截这样游客能看评论但提交评论必须先登录。放行配置有三个容易漏的地方登录和注册的POST提交接口也要放行否则用户提交登录表单时又被拦截陷入死循环。静态资源必须放行登录页没样式浏览器加载不了css/js页面看起来像裸奔。/error路径建议放行避免异常跳转遇到拦截器造成二次故障。5.3 无限重定向问题与排查思路这是登录注册里最常见的连环坑你写了拦截器但忘记把登录页路径放到excludePathPatterns里。未登录用户请求后台被拦截器重定向到/auth/login但/auth/login也在拦截范围内于是再次被重定向到/auth/login浏览器地址栏不断刷同一个地址页面一片空白。排查方法打开浏览器开发者工具看Network面板会看到/auth/login被重复请求。如果自己写的拦截器连登录页面都拦住十有八九是放行路径漏了。另一种表现是带context-path时重定向地址少了一段导致404。所以我习惯在sendRedirect里加request.getContextPath()不是直接写“/auth/login”。如果是Spring Boot项目完整路径应该是“/项目前缀/auth/login”。6. 实测中踩过的三个真实坑6.1 密码字段长度不足注册直接数据库报错这个坑我前面提醒过再展开说说完整的排查链路。现象注册页面一提交控制台直接抛SQLException报错内容类似Data too long for column password at row 1。排查步骤先看异常定位到userMapper.insert方法。打印要插入的BlogUser对象发现password这个属性值是一长串“$2a$10$……”字符串。数了一下长度恰好在60个字符左右。去看表结构password字段是varchar(32)当场破案。解决把password字段改成varchar(60)。以后凡是遇到“Data too long”第一件事是数一数实际写入的值到底多长再决定字段长度而不是凭感觉猜。6.2 中文乱码编码配置与模板声明不一致现象提交注册表单后用户名里的中文全部变成问号数据库里存的也是乱码。这个问题有三个环节可能出问题HTML页面本身不是UTF-8编码HTTP请求/响应的字符编码没有强制UTF-8数据库连接URL或表字段字符集不是UTF-8。我的解决方式server: servlet: encoding: charset: UTF-8 force: true数据库连接URL加上参数jdbc:mysql://localhost:3306/blog_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai还有HTML页面的meta标签也要带上charsetUTF-8Thymeleaf默认没问题但如果你用其他模板引擎很容易漏。实际项目中我最常漏的是第二项spring-boot-starter-web默认已经配了UTF-8但force: true会强制覆盖请求和响应头加了以后乱码几率大幅下降。6.3 重定向后错误提示消失Flash属性用法新手很容易写这样的代码model.addAttribute(errorMsg, 用户名或密码错误); return redirect:/auth/login;然后登录页拿不到errorMsg。原因是Redirect是一次全新的GET请求原来的Model数据根本不会带过去它们消失在第一次请求里了。正确方式是用RedirectAttributes.addFlashAttributera.addFlashAttribute(errorMsg, 用户名或密码错误); return redirect:/auth/login;FlashMap机制会在重定向前把数据临时放到Session里重定向后的第一次请求能读取并立即销毁。注意不要用它传大量数据它本身就不是给你做参数传递用的只适合放提示信息、跳转消息这类轻量数据。如果你要传业务对象老老实实用query参数或Session。7. 上线之前还能做的加固与扩展7.1 验证码与失败次数限制成本低见效快注册接口是垃圾脚本最爱刷的入口图形验证码是第一道便宜的防线。简单做法后端生成一个四位随机码把答案存Session用图片渲染出来。前端表单提交时带上用户输入的验证码后端比对。不引入第三方组件也可以用Java自带的BufferedImage画一张带干扰线的图片即可。生成验证码、存Session、比对的逻辑自己实现起来不难。登录接口另一个必做项是失败次数限制。我在项目里用内存Map存“IP用户名”级别的失败记录MapString, FailInfo failMap new ConcurrentHashMap(); // 每次登录失败时 FailInfo info failMap.computeIfAbsent(key, k - new FailInfo()); info.failCount; info.lastTime System.currentTimeMillis(); if (info.failCount 5 now - info.lastTime 5 * 60 * 1000) { throw new BizException(失败次数过多请5分钟后再试); } // 登录成功时删除该key这块有几个细节只按IP锁一个出口IP下的多个用户会被连带影响只按用户名锁攻击者可以通过持续失败把某个真实用户的账号直接锁掉。所以取“IP用户名”组合两边都限制误伤概率小很多。单机部署用内存Map没问题以后部署多实例再迁到Redis。7.2 从单角色到RBAC的演进第一版用户表只有role一个字符串字段足够区分管理员和普通用户。但博客系统如果以后加编辑、审稿人、运营等角色字符串字段会越写越乱。演进方向是经典的RBAC模型用户表、角色表、权限表以及用户-角色、角色-权限两张关联表。登录成功后查当前用户有哪些角色再汇总所有角色对应的权限集合放在Session或权限框架里。这样每个后端接口只要校验某个权限点不用关心用户具体是什么角色。我不建议一上来就引入完整Spring Security做权限。先把自研登录注册、Session、拦截器这套走通你会对认证和授权有更直观的理解以后真要引入框架也清楚它替你做了什么。7.3 登录审计与“记住我”功能登录审计这个习惯我从第二次做项目才开始补齐。实现很轻在doLogin成功之后往login_log表插一条记录包含用户ID、登录时间、登录IP、UserAgent。单用户博客看不出多大作用但多用户系统里出问题要定位“谁在什么时间干了什么”时这条记录就是救命稻草。“记住我”功能则是另一个常见诉求。实现思路是用随机token存Cookie同时把token的哈希和过期时间存到数据库。用户带着token回来时用token找到用户并重建Session。这里我特别要提一个反面教训之前为了图省事想过直接把用户ID放进Cookie甚至用明文用户名和密码放Cookie这种做法一旦Cookie泄露等于把账号双手奉上。存随机token、存哈希、设过期、用户退出时删除每一步都不能省。安全这件事从第一版就按正确的路径做后面维护会省很多事。