简介:这份资源面向JavaWeb初学者与课程设计实践者,提供一套可直接运行的登录与注册功能完整实现,帮助理解JSP页面展示、Servlet请求处理与MySQL数据存储的协作流程。压缩包共34个文件,约3.56MB,包含7个java源文件与7个class编译文件、3个jsp页面、2个xml配置、2个properties属性文件,以及sql建表脚本、jar依赖、js脚本和Eclipse工程配置等,覆盖从源码到部署的完整结构。已有3049人学习下载,说明其作为入门范例具有较高参考价值。读者可从中掌握表单提交与doPost响应、JDBC连接数据库、SELECT验证登录与INSERT完成注册、session保存登录状态等关键环节,并了解参数化查询防SQL注入、密码加密存储、用户名重复校验与错误提示等安全与体验处理思路,适合作为课程作业、实训项目或自学练手的参考模板。
1. 登录注册看着简单,为什么一上生产就漏洞百出
很多刚接触 JavaWeb 的开发者都有个错觉:登录注册不就是两个表单、一张用户表、一个跳转吗?我最初也这么想,直到某次帮一个朋友排查他上线没多久的小系统——用户表里躺着三条同名账号,密码字段是明文,登录接口用 GET 传参,Session 超时时间设成了永不过期。这套东西在本地跑起来毫无问题,一放到公网环境,各种问题全冒出来了。
基于 JavaWeb 实现的登录及注册,本质上是把「用户身份」这件事从浏览器到数据库完整走通一遍。它涉及前端表单校验、后端参数接收、密码加密存储、会话状态管理、数据库唯一约束、异常与安全处理这一整条链路。任何一个环节偷懒,都会变成后面擦不完的屁股。这篇文章面向的是正在做课程设计、练手项目或者小型管理系统后台的开发者,我会把这条链路拆成能直接抄作业的步骤,同时把那些只有踩过才知道的坑讲清楚。读完你应该能独立搭出一套结构清晰、能扛住基本安全审查的登录注册模块。
2. 从表单到数据库:一条完整链路的拆解与选型
2.1 为什么不用 Servlet 裸写,而是选 MVC 分层
早期学 JavaWeb,很多人是从 Servlet + JSP 直接开始的。一个 LoginServlet 里既接参数、又查数据库、还负责跳转,代码能跑,但维护起来是灾难。我一般会按 MVC 拆成三层:Controller 层负责接收请求和返回结果,Service 层负责业务逻辑(比如校验密码、生成会话),DAO 层负责和数据库打交道。这样做的好处是,当你要把密码加密方式从 MD5 换成 BCrypt 时,只需要动 Service 层,Controller 和 DAO 完全不用碰。
具体落地时,Controller 可以用原生 Servlet,也可以用 Spring MVC。对于练手项目,我建议先用原生 Servlet 把流程走通,理解 request、response、session 到底怎么流转,再去用框架。下面是一个典型的登录 Controller 骨架:
// LoginServlet.java @WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置编码,防止中文用户名乱码 req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); // 基础非空校验,别指望前端校验能拦住所有请求 if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { resp.getWriter().write("{\"code\":400,\"msg\":\"用户名或密码不能为空\"}"); return; } try { User user = userService.login(username.trim(), password); if (user == null) { resp.getWriter().write("{\"code\":401,\"msg\":\"用户名或密码错误\"}"); return; } // 登录成功,写入 Session HttpSession session = req.getSession(); session.setAttribute("userId", user.getId()); session.setAttribute("username", user.getUsername()); session.setMaxInactiveInterval(30 * 60); // 30 分钟超时 resp.getWriter().write("{\"code\":200,\"msg\":\"登录成功\"}"); } catch (Exception e) { // 生产环境别把堆栈直接返回给前端 resp.getWriter().write("{\"code\":500,\"msg\":\"服务器内部错误\"}"); } } }这段代码里几个关键点:setCharacterEncoding必须在读取参数之前调用,否则中文用户名会变成乱码;session.setMaxInactiveInterval显式设置超时,别用默认值;异常处理里不要e.printStackTrace()之后还把错误信息返回给前端,那等于把数据库结构告诉攻击者。
2.2 密码存储:为什么 MD5 已经不能用了
这是登录注册里最容易翻车的地方。我见过太多项目直接把密码明文存进数据库,或者用 MD5 加个固定盐。MD5 的问题在于计算太快,现代显卡每秒能算几十亿次,配合彩虹表,弱密码几乎秒破。正确做法是用专门为密码设计的慢哈希算法,比如 BCrypt 或 Argon2。
BCrypt 的核心优势是自带盐值、计算成本可调。每次加密同一个密码,结果都不一样,因为盐是随机生成的,并且盐值就存在最终的结果字符串里。验证时不需要单独存盐,直接比对即可。下面是一个工具类:
// PasswordUtil.java import org.mindrot.jbcrypt.BCrypt; public class PasswordUtil { // 工作因子,10 表示 2^10 次迭代,可根据服务器性能调整 private static final int WORK_FACTOR = 10; // 加密:返回的字符串已经包含盐值 public static String hash(String plainPassword) { return BCrypt.hashpw(plainPassword, BCrypt.gensalt(WORK_FACTOR)); } // 校验:从密文中解析盐值,重新计算后比对 public static boolean verify(String plainPassword, String hashed) { if (plainPassword == null || hashed == null) { return false; } try { return BCrypt.checkpw(plainPassword, hashed); } catch (IllegalArgumentException e) { // 密文格式不对时返回 false,不要抛异常给上层 return false; } } }参数说明:WORK_FACTOR每增加 1,计算时间翻倍。10 大概对应几十毫秒,对登录接口来说可以接受;如果服务器性能好可以调到 12,但别超过 14,否则单次登录会超过一秒,容易被当成拒绝服务攻击的入口。BCrypt.checkpw内部会从 hashed 字符串里提取盐值,所以数据库只需要存一个字段。
2.3 数据库表设计:唯一约束和字段类型
用户表看起来简单,但字段类型和约束没设好,后面会很难受。下面是我常用的建表语句:
CREATE TABLE `t_user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(32) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 密文', `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';几个细节:username加唯一索引,这是防止重复注册的最后一道防线,别只靠代码里的「先查再插」,并发下必然出问题。password字段给到 100,因为 BCrypt 密文固定 60 字符,留余量方便以后换算法。status字段用于软禁用,比直接删用户安全。字符集用utf8mb4,支持 emoji 和生僻字。
2.4 注册流程:先校验还是先入库
注册的流程比登录多一步写入。常见做法是:接收参数 → 格式校验 → 查重 → 加密 → 入库 → 返回结果。这里有个顺序问题:查重和入库之间存在时间窗口,两个请求同时到达可能都通过查重然后都插入。解决办法是依赖数据库唯一索引,捕获DuplicateKeyException后返回「用户名已存在」。
// UserServiceImpl.java 注册核心逻辑 public boolean register(String username, String rawPassword, String email) { // 1. 格式校验 if (username == null || username.length() < 4 || username.length() > 32) { throw new BizException("用户名长度需在 4-32 位之间"); } if (rawPassword == null || rawPassword.length() < 8) { throw new BizException("密码长度不能少于 8 位"); } // 2. 加密 String hashed = PasswordUtil.hash(rawPassword); // 3. 入库,依赖唯一索引兜底 try { User user = new User(); user.setUsername(username); user.setPassword(hashed); user.setEmail(email); return userDao.insert(user) > 0; } catch (DuplicateKeyException e) { throw new BizException("用户名已被占用"); } }逻辑说明:格式校验放在最前面,避免无谓的数据库查询;加密在入库前完成,保证任何路径下都不会有明文落库;捕获唯一键冲突而不是先查后插,这样在并发下也是安全的。参数上,用户名长度限制 4 到 32,密码最少 8 位,这些阈值可以根据业务调整,但不要放开到 1 位。
3. 会话管理:Session、Cookie 与登录态保持
3.1 Session 的工作原理与常见误用
用户登录成功后,服务器需要记住「这个人已经登录了」。JavaWeb 里最直接的方式是 HttpSession。服务器在内存里维护一个 Session 对象,把 sessionId 通过 Cookie 发给浏览器,后续请求带上这个 Cookie,服务器就能找到对应的 Session。
常见误用有几个:一是把 Session 当成永久存储,不设超时;二是把敏感信息比如密码也塞进 Session;三是在集群环境下还用默认的内存 Session,导致用户请求打到不同机器就掉线。对于单机练手项目,内存 Session 够用,但超时时间一定要设。我一般设 30 分钟,金融类业务可以缩短到 15 分钟。
// 登录成功后写入 Session 的标准写法 HttpSession session = req.getSession(true); // true 表示不存在就创建 session.setAttribute("userId", user.getId()); session.setAttribute("username", user.getUsername()); session.setMaxInactiveInterval(1800); // 单位秒,30 分钟注意:getSession(true)和getSession()等价,都会在不存在时创建。如果你只是想读取登录态而不想创建新会话,用getSession(false),返回 null 时说明未登录。
3.2 用过滤器统一拦截未登录请求
每个需要登录的接口都写一遍「判断 Session 是否存在」太啰嗦,而且容易漏。标准做法是写一个 Filter,拦截需要保护的路径。
// AuthFilter.java @WebFilter(urlPatterns = {"/user/*", "/order/*"}) public class AuthFilter implements Filter { // 白名单,不需要登录就能访问 private static final Set<String> WHITE_LIST = Set.of("/user/login", "/user/register"); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String path = req.getRequestURI().substring(req.getContextPath().length()); if (WHITE_LIST.contains(path)) { chain.doFilter(request, response); return; } HttpSession session = req.getSession(false); if (session == null || session.getAttribute("userId") == null) { // 未登录,返回 401 而不是重定向,方便前端统一处理 resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); return; } chain.doFilter(request, response); } }逻辑说明:白名单里的路径直接放行;其余路径检查 Session,没有登录态就返回 401。这里用返回 JSON 而不是重定向到登录页,是因为前后端分离场景下重定向会让前端拿到一个 HTML 页面,处理起来很别扭。参数上,urlPatterns按你的业务路径调整,白名单一定要包含登录和注册接口本身,否则会死循环。
3.3 退出登录:别只删一个属性
退出登录的正确做法是让整个 Session 失效,而不是只删掉 userId 属性。因为 Session 里可能还有其他属性,只删一个等于没退干净。
// LogoutServlet.java @WebServlet("/user/logout") public class LogoutServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(false); if (session != null) { session.invalidate(); // 让整个会话失效 } resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":200,\"msg\":\"已退出\"}"); } }invalidate()会清除 Session 中的所有属性并通知容器回收。调用之后,原来的 sessionId 就失效了,即使浏览器还带着旧 Cookie,服务器也找不到对应会话。
4. 避坑指南:登录注册里最容易翻车的五个地方
4.1 坑一:中文用户名变成问号
现象:注册时输入中文用户名,数据库里存的是???或者乱码。
原因:请求编码没有设置,Tomcat 默认按 ISO-8859-1 解析 POST 请求体。GET 请求的编码由 server.xml 的 Connector 配置决定,更隐蔽。
解决:在 Filter 里统一设置request.setCharacterEncoding("UTF-8"),并且这个 Filter 要配置在所有 Filter 的最前面。数据库连接 URL 也要加上useUnicode=true&characterEncoding=utf8。如果是 GET 请求,建议在 Tomcat 的 Connector 上加URIEncoding="UTF-8"。
4.2 坑二:登录接口用 GET,密码出现在 URL 里
现象:浏览器地址栏能看到?username=admin&password=123456,服务器日志、代理日志里全是明文密码。
原因:表单 method 写成了 get,或者前端用 axios 时没指定 post。
解决:登录注册一律用 POST,并且给表单加autocomplete="off"减少浏览器记住密码的风险。后端接口层面,可以在 Filter 里判断请求方法,非 POST 直接拒绝。
4.3 坑三:Session 固定攻击
现象:攻击者先访问网站拿到一个 sessionId,然后诱导受害者用这个 sessionId 登录,之后攻击者就能用同一个 sessionId 访问受害者的账号。
原因:登录成功后没有更换 sessionId,沿用登录前的会话。
解决:登录成功后调用req.changeSessionId()(Servlet 3.1+),或者先invalidate()再getSession(true)创建新会话。这一步很多人不知道,但它是防会话固定的标准操作。
// 登录成功后更换 sessionId req.changeSessionId(); HttpSession session = req.getSession(); session.setAttribute("userId", user.getId());4.4 坑四:用户名枚举
现象:登录失败时,如果用户名不存在返回「用户不存在」,密码错误返回「密码错误」,攻击者就能批量试出哪些用户名是有效的。
原因:错误提示太具体。
解决:统一返回「用户名或密码错误」,不区分是哪个环节出错。注册时的「用户名已被占用」虽然也暴露了用户名存在,但注册场景下这个提示是必要的,可以通过加图形验证码来缓解批量探测。
4.5 坑五:数据库连接没关,跑一会儿就崩
现象:本地测试正常,部署后运行一段时间开始报「Too many connections」。
原因:DAO 层每次操作都新建 Connection,用完没有 close,或者异常路径下 close 被跳过。
解决:用 try-with-resources 确保连接释放,或者引入连接池(如 HikariCP)。下面是一个安全的查询写法:
// UserDaoImpl.java 查询用户 public User findByUsername(String username) { String sql = "SELECT id, username, password, email, status FROM t_user WHERE username = ?"; try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setEmail(rs.getString("email")); user.setStatus(rs.getInt("status")); return user; } } } catch (SQLException e) { throw new RuntimeException("查询用户失败", e); } return null; }try-with-resources 保证无论是否抛异常,Connection、PreparedStatement、ResultSet 都会按顺序关闭。用 PreparedStatement 而不是 Statement,还能顺带防 SQL 注入。
5. 进阶技巧:让登录注册更接近生产可用
5.1 加一层图形验证码,挡住脚本批量请求
登录接口如果没有频率限制,攻击者可以用字典无限尝试。加图形验证码是最低成本的缓解手段。实现思路是:生成随机字符串,画到图片上,把答案存进 Session,登录时先比对验证码再校验密码。
// CaptchaServlet.java 生成验证码 @WebServlet("/captcha") public class CaptchaServlet extends HttpServlet { private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { int width = 120, height = 40; BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); Random random = new Random(); StringBuilder code = new StringBuilder(); g.setFont(new Font("Arial", Font.BOLD, 24)); for (int i = 0; i < 4; i++) { char c = CHARS.charAt(random.nextInt(CHARS.length())); code.append(c); g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150))); g.drawString(String.valueOf(c), 15 + i * 25, 30); } // 加几条干扰线 for (int i = 0; i < 5; i++) { g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200))); g.drawLine(random.nextInt(width), random.nextInt(height), random.nextInt(width), random.nextInt(height)); } g.dispose(); // 验证码答案存入 Session,2 分钟有效 req.getSession().setAttribute("captcha", code.toString().toLowerCase()); req.getSession().setMaxInactiveInterval(120); resp.setContentType("image/png"); ImageIO.write(image, "png", resp.getOutputStream()); } }登录校验时,从 Session 取出 captcha 比对,比对完立即删除,防止同一个验证码被复用。注意验证码字符集去掉了容易混淆的 0、O、1、I,这是血泪经验,用户看不清会反复刷新。
5.2 登录失败次数限制
除了验证码,还可以在 Service 层记录失败次数。简单做法是用一个带过期时间的 Map(比如 Guava Cache 或 Caffeine),key 是用户名,value 是失败次数,超过 5 次锁定 15 分钟。
// 用 Caffeine 做失败计数 private Cache<String, Integer> failCache = Caffeine.newBuilder() .expireAfterWrite(15, TimeUnit.MINUTES) .maximumSize(10000) .build(); public User login(String username, String rawPassword) { Integer fails = failCache.getIfPresent(username); if (fails != null && fails >= 5) { throw new BizException("失败次数过多,请 15 分钟后再试"); } User user = userDao.findByUsername(username); if (user == null || !PasswordUtil.verify(rawPassword, user.getPassword())) { failCache.put(username, fails == null ? 1 : fails + 1); return null; } failCache.invalidate(username); // 登录成功清除计数 return user; }参数说明:expireAfterWrite设 15 分钟,意味着从最后一次失败开始算,15 分钟后计数自动清零。maximumSize限制缓存条目数,防止内存被撑爆。阈值 5 次可以根据业务调整,但不要设得太高,否则限制形同虚设。
5.3 用表格对比几种密码算法的取舍
| 算法 | 是否自带盐 | 计算速度 | 推荐场景 | 备注 |
|---|---|---|---|---|
| 明文 | 无 | 无 | 绝对不用 | 任何理由都不成立 |
| MD5 | 需手动加盐 | 极快 | 已淘汰 | 彩虹表秒破 |
| SHA-256 | 需手动加盐 | 快 | 不推荐用于密码 | 同样容易被暴力破解 |
| BCrypt | 自带 | 可调慢 | 推荐 | 工作因子 10-12 |
| Argon2 | 自带 | 可调 | 推荐 | 内存硬,抗 GPU 更好 |
选型建议:新项目直接用 BCrypt,库成熟、接入简单。如果对安全要求更高且有条件,可以上 Argon2。无论选哪个,都不要自己发明加密方案。
5.4 一个容易被忽略的细节:注册和登录的响应时间
登录接口如果用户名不存在时立刻返回,而用户名存在时因为要跑 BCrypt 校验会慢几十毫秒,攻击者可以通过响应时间差判断用户名是否存在。解决办法是用户名不存在时也跑一次假的 BCrypt 校验,让两条路径耗时接近。
// 防时间侧信道:用户不存在时也做一次哈希运算 private static final String DUMMY_HASH = BCrypt.hashpw("dummy", BCrypt.gensalt(10)); public User login(String username, String rawPassword) { User user = userDao.findByUsername(username); if (user == null) { // 跑一次假校验,消耗与真实校验相近的时间 BCrypt.checkpw(rawPassword, DUMMY_HASH); return null; } if (!PasswordUtil.verify(rawPassword, user.getPassword())) { return null; } return user; }这个技巧在普通练手项目里不是必须的,但如果你做的系统涉及真实用户,加上它成本很低。我自己在几个后台系统里都保留了这个习惯,后来做安全扫描时确实没被报出时间侧信道问题。
最后说个我自己的教训:早期做项目时总觉得登录注册太简单,不值得花时间,结果每次出安全问题都是这块。后来我养成了一个习惯,任何新项目先把用户表、密码工具类、认证过滤器这三样东西按标准写好,后面再动业务代码就踏实很多。希望帮到你。
本文还有配套的精品资源,点击获取