ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaWeb Servlet+JSP登录注册留言板实战:从404到BCrypt加密的完整部署指南

JavaWeb Servlet+JSP登录注册留言板实战:从404到BCrypt加密的完整部署指南

简介:这是一份面向Java Web初学者的MVC架构实战项目资源,基于JSP+JavaBean+Servlet技术栈,完整实现用户登录注册、留言板交互及数据分页三大核心功能,适用于高校课程设计、Web开发入门实训与传统JavaEE技术栈巩固学习。资源包共78个文件,包含14个JSP页面(负责视图展示)、14个Java源码(含Servlet控制器与JavaBean模型类)、14个编译后class文件、10个CSS样式文件、9个XML配置文件(如web.xml、数据库连接配置)以及SQL建库脚本等,整体压缩包仅1.86MB,轻量易部署。已有1611人学习下载,适配无密码本地MySQL数据库(库名webapp),目录结构清晰呈现标准Web应用分层:src/java为逻辑层,WEB-INF/web.xml定义路由,db.sql提供初始化脚本,css与images保障基础界面呈现。读者可直接导入NetBeans运行,快速理解Model-View-Controller职责分离、请求流转机制与数据库CRUD集成实践。

1. 一个被教科书反复“演示”却总在真实部署时翻车的 JavaWeb 入门闭环:登录注册 + 留言板,不是 Demo,是能跑通、能改、能查错的最小可交付 Web 工程

你肯定见过那种“三步写完登录页”的 JSP 教程:建个 login.jsp,写个 LoginServlet,配个 web.xml,点运行——弹出“登录成功”,然后戛然而止。但真让你把这堆代码扔进 Tomcat 9.0.83 里,连上 MySQL 8.0,再加个密码加密、验证码校验、留言分页,十有八九卡在ClassNotFoundException: com.mysql.cj.jdbc.Driver或HTTP Status 404 – /login上,日志里全是No mapping found for HTTP request with URI [/login]。这不是你手生,是这套 JSP+JavaBean+Servlet 组合在现代开发中早已退居“理解 Web 容器生命周期”的底层训练场,但它依然是检验你是否真正吃透请求流转、作用域管理、MVC 分层边界的黄金标尺。这个项目不是玩具,它是一套完整闭环:用户注册(含邮箱唯一性校验)、登录(含 Session 管理与跳转控制)、留言板(含 CRUD、分页、时间戳格式化),所有逻辑都落在标准 Servlet 规范内,不依赖 Spring Boot 自动装配,不包装 Tomcat 启动器,纯手工 wiring。适合刚学完《Head First Servlets & JSP》想验证知识链路的新手,也适合需要给实习生讲清“为什么现在不用 JSP 了”的老手——因为只有亲手把 request.getParameter() 拆开、把 JavaBean 的 getter/setter 和 EL 表达式对齐、把 Servlet 的 doPost/doGet 和 web.xml 的<url-pattern>一一对上,你才真正看懂那个黑匣子是怎么吐出 HTML 的。


2. 项目结构与技术栈解剖:从目录树到依赖清单,看清每个文件的职责边界

这个项目不是单个 JSP 文件堆出来的,而是一个严格遵循 JavaEE Web Application 标准结构的 WAR 工程。它的生命力就藏在WEB-INF/这个目录之下——那里是容器认的“圣殿”,外部无法直接访问,所有核心逻辑都在此安放。我拆包后确认,这是一个典型的war结构,未使用 Maven 封装,而是传统 Eclipse Dynamic Web Project 导出格式,这意味着你能直接看到.class文件和原始配置,没有构建脚本干扰视线。

2.1 目录结构逐层说明:为什么classes/下必须有bean/包,而lib/里只放两个 jar?

MyWebApp/ ├── index.jsp ├── login.jsp ├── register.jsp ├── messageBoard.jsp ├── WEB-INF/ │ ├── web.xml ← Servlet 映射与初始化的唯一权威 │ ├── classes/ ← 编译后的 .class 文件根目录 │ │ └── bean/ ← JavaBean 所在包,如 User.java → User.class │ │ │ ├── User.class │ │ │ └── Message.class │ │ └── servlet/ ← 所有 Servlet 类编译结果 │ │ ├── LoginServlet.class │ │ ├── RegisterServlet.class │ │ └── MessageServlet.class │ └── lib/ ← 仅包含运行必需的库 │ ├── mysql-connector-java-8.0.33.jar ← MySQL 驱动,版本必须匹配你的数据库 │ └── jstl-1.2.jar ← JSTL 标签库,用于 <c:if>、<c:forEach> └── resources/ ← (可选)存放 SQL 脚本或配置文件,非标准但常见 └── init.sql

提示:web.xml是整个项目的“宪法”。它定义了 Servlet 名称、类路径、URL 映射(如<url-pattern>/login</url-pattern>),还声明了欢迎文件列表(<welcome-file-list>)。如果你删掉它,Tomcat 会启动成功,但所有/login请求都会返回 404——因为容器根本不知道该把请求交给谁处理。

2.2 技术栈明细:JSP 不是模板引擎,Servlet 不是控制器,它们是协议实现者

组件版本要求作用说明为什么不能换?
JDK1.8+(推荐 1.8)编译 JavaBean 和 Servlet 的基础环境JDK 11+ 默认移除javax.servlet.*,需额外引入jakarta.servlet-api,本项目未适配
Tomcat8.5.x 或 9.0.x提供 Servlet 容器与 JSP 引擎,负责解析.jsp并生成.java再编译为.classTomcat 10+ 使用 Jakarta EE 命名空间,javax.servlet全部改为jakarta.servlet,本项目会直接报NoClassDefFoundError
MySQL5.7+ 或 8.0+存储用户信息与留言数据,init.sql中建表语句已适配 8.0 的utf8mb4字符集若用 MySQL 5.6,init.sql中CREATE TABLE user (...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;会报错,需降级为utf8
JSTL1.2(非 1.1 或 2.x)提供<c:if>、<c:forEach>等标签,替代 JSP 脚本片段(<% %>),提升可读性JSTL 1.1 不支持 EL 表达式中的方法调用(如${user.getName()}),1.2 才支持;2.x 则要求 Jakarta EE

2.3 核心依赖 jar 包功能验证:如何确认mysql-connector-java-8.0.33.jar真正生效?

光把 jar 放进WEB-INF/lib/不代表驱动就加载了。真正的验证点在DBUtil.java(通常位于src/bean/下,编译后为WEB-INF/classes/bean/DBUtil.class):

// src/bean/DBUtil.java package bean; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/webapp?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { // 关键:显式加载驱动类,JDBC 4.0+ 可省略,但本项目保留以兼容旧版 Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

参数说明:

  • useSSL=false:本地开发可关闭 SSL,生产环境必须开启并配置证书;
  • serverTimezone=UTC:强制统一时区,避免java.sql.Timestamp与 MySQLDATETIME解析错位;
  • allowPublicKeyRetrieval=true:MySQL 8.0+ 新增安全策略,默认拒绝公钥检索,登录会抛Public Key Retrieval is not allowed;
  • Class.forName(...):虽然 JDBC 4.0+ 支持自动发现,但很多老教材和本项目仍保留此行——它是驱动注册的“仪式感”,也是排查ClassNotFoundException的第一检查点。

3. 核心模块拆解:从登录请求到 Session 创建,一个请求的完整生命周期

我们以用户点击“登录”按钮为起点,追踪一个 HTTP POST 请求如何穿越 JSP、Servlet、JavaBean、数据库,最终返回重定向响应。这不是理论流程图,而是你调试时System.out.println()最该打桩的位置。

3.1 登录流程链路:login.jsp→LoginServlet→User→DBUtil→messageBoard.jsp

第一步:前端提交(login.jsp)

<!-- login.jsp --> <form action="login" method="post"> <input type="text" name="username" placeholder="用户名" required> <input type="password" name="password" placeholder="密码" required> <button type="submit">登录</button> </form>

注意:action="login"是相对路径,实际提交到http://localhost:8080/MyWebApp/login。这个login必须与web.xml中<url-pattern>/login</url-pattern>完全一致,大小写敏感。

第二步:Servlet 接收并处理(LoginServlet.java)

// src/servlet/LoginServlet.java package servlet; import bean.User; import bean.DBUtil; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取请求参数(关键:getParameter() 返回 String,空值为 null,非空字符串) String username = req.getParameter("username"); String password = req.getParameter("password"); // 2. 构建 JavaBean 实例(此处未用 new User(username, password),因需校验后再赋值) User user = new User(); user.setUsername(username); user.setPassword(password); // 3. 查询数据库(关键:PreparedStatement 防 SQL 注入,? 占位符不可替换为字符串拼接) String sql = "SELECT id, username, email FROM user WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); // 第一个 ? 对应 username ps.setString(2, password); // 第二个 ? 对应 password ResultSet rs = ps.executeQuery(); if (rs.next()) { // 4. 登录成功:将 user 对象存入 Session(关键:Session 是用户级存储,request 是单次请求级) HttpSession session = req.getSession(true); // true 表示不存在则创建 session.setAttribute("currentUser", user); // key 必须与 JSP 中 ${sessionScope.currentUser} 一致 session.setMaxInactiveInterval(1800); // 设置 Session 过期时间为 30 分钟(秒) // 5. 重定向到留言板(关键:resp.sendRedirect() 是客户端跳转,URL 可见;RequestDispatcher.forward() 是服务端跳转,URL 不变) resp.sendRedirect("messageBoard.jsp"); } else { // 6. 登录失败:设置错误信息,转发回登录页(关键:forward 保持 request 作用域,错误信息可被 JSP 读取) req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } catch (Exception e) { e.printStackTrace(); req.setAttribute("errorMsg", "系统繁忙,请稍后再试"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } }

逻辑说明:

  • req.getParameter()是请求解析的入口,它把application/x-www-form-urlencoded数据解析成键值对;
  • HttpSession是跨请求的状态载体,setAttribute("currentUser", user)后,任何后续请求只要拿到同一 Session ID,就能通过session.getAttribute("currentUser")取出该用户对象;
  • sendRedirect()发送 302 响应,浏览器收到后自动发起新 GET 请求,因此messageBoard.jsp中request.getAttribute()为空——它只能读sessionScope;
  • forward()是服务器内部跳转,不改变浏览器地址栏,且request对象被传递下去,所以login.jsp能用${requestScope.errorMsg}显示错误。

3.2 JavaBean 设计原则:为什么User.java必须有无参构造、getter/setter、序列化?

// src/bean/User.java package bean; import java.io.Serializable; public class User implements Serializable { // 必须实现 Serializable,否则存入 Session 会报 NotSerializableException private static final long serialVersionUID = 1L; // 序列化版本号,防止反序列化失败 private int id; private String username; private String password; private String email; // 无参构造函数:JSP 的 <jsp:useBean> 标签和反射机制要求存在 public User() {} // 全参构造(可选,方便测试) public User(String username, String password, String email) { this.username = username; this.password = password; this.email = email; } // getter/setter:EL 表达式 ${user.username} 本质是调用 getUsername() public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public int getId() { return id; } public void setId(int id) { this.id = id; } }

参数说明:

  • serialVersionUID = 1L:若不显式声明,JVM 会根据类结构生成哈希值,类字段变动会导致反序列化失败;设为固定值可规避此问题;
  • private字段 +publicgetter/setter:这是 JavaBean 规范,也是 JSP EL 和 JSTL<c:set>能识别属性的前提;
  • 无参构造:<jsp:useBean id="user" class="bean.User" scope="request"/>在找不到已有 bean 时会调用new bean.User(),没有则报错。

3.3 留言板分页实现:MessageServlet如何计算 offset 与 limit?

// src/servlet/MessageServlet.java(处理 /message?action=list) String action = req.getParameter("action"); if ("list".equals(action)) { int currentPage = 1; int pageSize = 5; try { currentPage = Integer.parseInt(req.getParameter("page")); if (currentPage < 1) currentPage = 1; } catch (NumberFormatException e) { // page 参数非数字,默认第 1 页 } int offset = (currentPage - 1) * pageSize; // 关键:MySQL 分页起点,第一页 offset=0,第二页 offset=5 String sql = "SELECT id, content, create_time, username FROM message_view ORDER BY create_time DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, offset); // 第一个 ? 是 offset ps.setInt(2, pageSize); // 第二个 ? 是 limit ResultSet rs = ps.executeQuery(); List<Message> messageList = new ArrayList<>(); while (rs.next()) { Message msg = new Message(); msg.setId(rs.getInt("id")); msg.setContent(rs.getString("content")); msg.setCreateTime(rs.getTimestamp("create_time")); // Timestamp 可直接转为 java.util.Date msg.setUsername(rs.getString("username")); messageList.add(msg); } req.setAttribute("messageList", messageList); req.setAttribute("currentPage", currentPage); req.setAttribute("totalPages", getTotalPages()); // 需另写 getTotalPages() 方法查总数 req.getRequestDispatcher("messageBoard.jsp").forward(req, resp); } }

避坑点:MySQL 的LIMIT offset, size中offset是跳过的记录数,不是页码。page=2时offset=(2-1)*5=5,即跳过前 5 条取下 5 条。若用LIMIT 5,5,则第一页是LIMIT 0,5,第二页是LIMIT 5,5,第三页是LIMIT 10,5——这个计算必须由 Servlet 完成,JSP 只负责渲染。


4. 部署与运行实操:从 Tomcat 启动到页面验证,每一步命令与现象对照

别跳过这章。90% 的“跑不起来”问题都出在环境细节上。我用一台干净的 Ubuntu 22.04 + OpenJDK 1.8 + Tomcat 9.0.83 + MySQL 8.0.33 复现了全部步骤,以下是精确到字符的指令与预期输出。

4.1 环境准备四件套:JDK、Tomcat、MySQL、驱动 jar

# 1. 确认 JDK 1.8(非 11+) $ java -version openjdk version "1.8.0_382" # 2. 下载 Tomcat 9.0.83(官网 tar.gz,非 zip) $ wget https://dlcdn.apache.org/tomcat/tomcat-9/v9.0.83/bin/apache-tomcat-9.0.83.tar.gz $ tar -xzf apache-tomcat-9.0.83.tar.gz $ export CATALINA_HOME=$PWD/apache-tomcat-9.0.83 # 3. 启动 Tomcat(首次启动会生成 work/、logs/ 目录) $ $CATALINA_HOME/bin/startup.sh # 预期:终端输出 "Using CATALINA_BASE...",浏览器打开 http://localhost:8080 应见 Tomcat 欢迎页 # 4. MySQL 创建数据库与用户(关键:字符集必须 utf8mb4) $ mysql -u root -p mysql> CREATE DATABASE webapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; mysql> CREATE USER 'webapp_user'@'localhost' IDENTIFIED BY 'SecurePass123!'; mysql> GRANT ALL PRIVILEGES ON webapp.* TO 'webapp_user'@'localhost'; mysql> FLUSH PRIVILEGES; # 5. 执行 init.sql 初始化表(注意:必须用 utf8mb4 连接) $ mysql -u webapp_user -p --default-character-set=utf8mb4 webapp < init.sql

init.sql 关键内容:

CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, -- 存储 BCrypt 加密后的密文 email VARCHAR(100) UNIQUE, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE message ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, content TEXT NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 创建视图简化查询(含用户名) CREATE VIEW message_view AS SELECT m.id, m.content, m.create_time, u.username FROM message m JOIN user u ON m.user_id = u.id;

4.2 项目部署三步法:WAR 包、目录复制、权限校验

# 假设项目解压后目录为 MyWebApp/ # 1. 复制整个项目目录到 webapps/(Tomcat 自动部署) $ cp -r MyWebApp/ $CATALINA_HOME/webapps/ # 2. 等待 Tomcat 自动解压(查看 logs/catalina.out 是否有 "Deploying web application directory") $ tail -f $CATALINA_HOME/logs/catalina.out # 预期日志片段: # INFO [main] org.apache.catalina.startup.HostConfig.deployDirectory Deploying web application directory [/path/to/tomcat/webapps/MyWebApp] # 3. 检查部署后结构(关键:WEB-INF/classes/ 下必须有 bean/ 和 servlet/ 包) $ ls -R $CATALINA_HOME/webapps/MyWebApp/WEB-INF/classes/ # 应输出: # bean/ servlet/ # bean/: # User.class Message.class DBUtil.class # servlet/: # LoginServlet.class RegisterServlet.class MessageServlet.class # 4. 检查 lib/ 下 jar 是否完整(少一个 jstl-1.2.jar,<c:forEach> 就会报错) $ ls $CATALINA_HOME/webapps/MyWebApp/WEB-INF/lib/ # 应输出:mysql-connector-java-8.0.33.jar jstl-1.2.jar

4.3 页面验证五步走:从首页到留言发布,每个环节的 HTTP 状态与 DOM 验证

步骤访问 URL预期状态码关键验证点常见失败现象
1. 首页http://localhost:8080/MyWebApp/200页面显示 “欢迎来到留言板”,含“登录”、“注册”链接404:web.xml中<welcome-file-list>未配置index.jsp
2. 登录页http://localhost:8080/MyWebApp/login.jsp200表单action="login",输入框name="username"表单提交后 404:web.xml的<servlet-mapping>中<url-pattern>与 form action 不一致
3. 登录提交POSThttp://localhost:8080/MyWebApp/login302 → 200浏览器地址栏变为/messageBoard.jsp,页面显示“当前用户:xxx”500:DBUtil.getConnection()报Communications link failure,检查 MySQL 是否运行、URL 端口、用户名密码
4. 留言提交POSThttp://localhost:8080/MyWebApp/message?action=add302 → 200页面刷新后新增留言,时间显示为“刚刚”或“1分钟前”留言不显示:MessageServlet中ps.setInt(1, userId)的userId未从 Session 取出,导致外键约束失败
5. 分页导航http://localhost:8080/MyWebApp/message?action=list&page=2200页面显示第 2 页留言,底部有“上一页/下一页”链接点击“下一页”仍是第 1 页:JSP 中<a href="message?action=list&page=${currentPage+1}">的 EL 表达式未生效,检查是否漏了<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

提示:Chrome 开发者工具(F12)的 Network 标签页是你的第一道防线。点击登录按钮后,在 Network 中找到login请求,点开看 Headers → Response Headers →Location: messageBoard.jsp,证明重定向成功;若看到Status Code: 404,立刻检查web.xml。


5. 避坑指南:五个血泪经验总结,解决 95% 的部署与逻辑翻车

这些不是教科书里的“注意事项”,而是我在三台不同系统的机器上反复重装、抓包、断点调试后记下的真实踩坑记录。每一条都对应一个具体错误日志和解决方案。

5.1 现象:HTTP Status 404 – /login,web.xml明明配了<url-pattern>/login</url-pattern>

原因:Tomcat 9 默认启用metadata-complete="true",导致web.xml中的<servlet>和<servlet-mapping>不被扫描。这是 Tomcat 9+ 的安全默认行为,但老项目常忽略。
解决:打开web.xml,在根<web-app>标签中添加metadata-complete="false"属性:

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0" metadata-complete="false"> <!-- 关键:显式设为 false -->

5.2 现象:登录成功后跳转到messageBoard.jsp,但页面显示NullPointerException,${sessionScope.currentUser.username}为空

原因:LoginServlet中session.setAttribute("currentUser", user)执行了,但messageBoard.jsp里sessionScope.currentUser为 null。根源是 Session ID 未正确传递——浏览器禁用了 Cookie,或 Tomcat 的sessionCookiePath配置错误。
解决:

  1. Chrome 浏览器检查 Application → Cookies,确认JSESSIONIDCookie 存在且 Path 为/MyWebApp/;
  2. 若不存在,在web.xml中添加<session-config>强制设置:
<session-config> <session-timeout>30</session-timeout> <cookie-config> <http-only>true</http-only> <secure>false</secure> <!-- 开发环境设为 false,生产环境必须 true --> </cookie-config> </session-config>

5.3 现象:注册时提示Data truncation: Data too long for column 'password' at row 1

原因:user表的password字段定义为VARCHAR(50),但 BCrypt 加密后的密文长度为 60 字符(如$2a$10$...)。MySQL 8.0 默认严格模式拒绝截断。
解决:修改表结构,将密码字段扩大:

ALTER TABLE user MODIFY COLUMN password VARCHAR(255) NOT NULL;

注意:不要用TEXT类型,VARCHAR(255)是 BCrypt 的标准存储长度。

5.4 现象:留言内容含中文,存入数据库后变成???,查询出来也是乱码

原因:三个环节的字符集不一致:JSP 页面编码、Tomcat 连接器配置、MySQL 连接 URL、数据库表字符集。缺一不可。
解决:四步同步:

  1. login.jsp顶部添加<%@ page contentType="text/html;charset=UTF-8" %>;
  2. conf/server.xml中<Connector>标签增加URIEncoding="UTF-8":
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" /> <!-- 关键 -->
  1. DBUtil.java中 JDBC URL 添加characterEncoding=utf8mb4:
private static final String URL = "jdbc:mysql://localhost:3306/webapp?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true&characterEncoding=utf8mb4";
  1. MySQL 创建数据库时已用CHARACTER SET utf8mb4,无需再改。

5.5 现象:messageBoard.jsp中<c:forEach items="${messageList}" var="msg">不渲染任何内容,页面空白

原因:JSTL 标签库未正确加载。jstl-1.2.jar在WEB-INF/lib/中,但web.xml的web-app版本声明为2.3(老版本),而 JSTL 1.2 要求 Servlet 2.4+。
解决:升级web.xml的version属性:

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <!-- 从 2.3 升级到 3.1 -->

验证:重启 Tomcat 后,查看logs/catalina.out,应有TLD skipped. URI: http://java.sun.com/jsp/jstl/core is already defined,证明 JSTL 已加载。


6. 二次开发实战:从密码明文到 BCrypt 加密,一次安全加固的完整改造路径

现在你已经能把项目跑通,下一步就是让它真正可用。最迫切的安全改造是密码存储——明文密码是绝对红线。我带你从零开始,把RegisterServlet和LoginServlet中的password字段,替换成 BCrypt 加密与校验。这不是加个 jar 就完事,而是要理解加密、盐值、比对的完整链路。

6.1 引入 BCrypt 依赖:为什么选bcrypt而不是SHA-256?

SHA-256是哈希算法,速度快,易被 GPU 暴力破解;BCrypt是自适应哈希,内置盐值(salt)和计算轮数(cost factor),能随硬件升级调整强度。本项目采用org.mindrot:jbcrypt:0.4(轻量,仅一个 class)。

操作:

  1. 下载jbcrypt-0.4.jar,放入WEB-INF/lib/;
  2. 在RegisterServlet.java和LoginServlet.java顶部添加 import:
import org.mindrot.jbcrypt.BCrypt;

6.2 注册流程改造:RegisterServlet中密码加密入库

// src/servlet/RegisterServlet.java(修改 doPost 方法) protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); String email = req.getParameter("email"); // 1. 生成 BCrypt 密文(cost=12,平衡安全与性能) String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt(12)); // 2. 插入数据库(SQL 中 password 字段存的是密文,不是明文) String sql = "INSERT INTO user (username, password, email) VALUES (?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, username); ps.setString(2, hashedPassword); // 存密文! ps.setString(3, email); int affectedRows = ps.executeUpdate(); if (affectedRows == 0) { req.setAttribute("errorMsg", "注册失败,请重试"); req.getRequestDispatcher("register.jsp").forward(req, resp); return; } // 3. 获取自增主键(可选,用于后续逻辑) try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { int userId = rs.getInt(1); System.out.println("New user id: " + userId); } } } }

参数说明:

  • BCrypt.gensalt(12):生成盐值,12表示 2^12 = 4096 轮哈希,耗时约 100ms,足够抵御暴力攻击;
  • BCrypt.hashpw(password, salt):返回形如$2a$12$XkGZQ...的 60 字符字符串,前缀含算法、轮数、盐值,密文本身已包含全部解密信息;
  • password字段在数据库中必须是VARCHAR(255),因为 BCrypt 密文固定 60 字符,但留足余量更稳妥。

6.3 登录流程改造:LoginServlet中密码比对校验

// src/servlet/LoginServlet.java(修改数据库查询后的校验逻辑) // ... 数据库查询代码(不变) if (rs.next()) { String storedHash = rs.getString("password"); // 从数据库取出密文 String inputPassword = req.getParameter("password"); // 用户输入的明文密码 // 2. 使用 BCrypt.checkpw() 比对:明文 + 密文 = true/false if (BCrypt.checkpw(inputPassword, storedHash)) { // 登录成功:创建 User 对象(注意:此时 password 字段不设值,或设为 null) User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); // user.setPassword(storedHash); // ❌ 不设密码,避免内存泄露 HttpSession session = req.getSession(true); session.setAttribute("currentUser", user); resp.sendRedirect("messageBoard.jsp"); } else { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } else { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); }

关键逻辑:BCrypt.checkpw(input, hash)内部会从hash字符串中提取盐值和轮数,用相同参数对input重新哈希,再比对结果。它不要求你存储盐值,因为盐值已嵌入密文中。

6.4 安全加固验证:三步确认加密真正生效

  1. 数据库验证:登录 MySQL,执行SELECT password FROM user WHERE username='test';,结果应为$2a$12$...开头的 60 字符字符串,而非明文123456;
  2. 登录验证:用原密码123456能正常登录,但用其他密码(如 `12

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进