简介:这份资源面向JavaWeb初学者与课程实践者,提供一套可直接运行的登录与注册功能完整实现,帮助理解JSP、Servlet与MySQL协同开发的核心流程。压缩包共34个文件,约3.56MB,包含7个java源文件与7个class编译文件、3个jsp页面、2个xml配置、2个properties属性文件,以及sql建表脚本、jar依赖、js脚本和Eclipse工程配置等,覆盖从页面表单、后端请求处理到数据库连接与用户信息存储的完整链路。资源重点演示了JDBC连接MySQL、参数化查询防SQL注入、session保存登录状态、注册信息校验与密码加密等关键知识点,并涉及错误提示与页面跳转等用户体验处理。目前已有3049人学习下载,适合作为课程设计、毕业设计或自学练手项目,读者可据此快速搭建环境、对照源码理解请求响应流程,并在此基础上扩展邮箱验证、权限控制等功能。
1. 登录注册看着简单,为什么一上生产就漏洞百出
很多刚接触 JavaWeb 的开发者觉得登录注册就是两个表单加一张用户表,半天就能写完。但真正做过线上系统的人都知道,这块代码是安全问题的重灾区:明文存密码、SQL 注入、会话固定攻击、暴力破解、越权访问,随便一个都能让整个系统翻车。我见过一个内部管理系统,注册接口没做任何校验,被人用脚本批量注册了十几万条垃圾账号,数据库直接撑爆。
这个标题讲的是用 JavaWeb 技术栈实现一套可用的登录注册模块,核心不是“能跑”,而是“能扛”。它适合正在学 Servlet/JSP 的学生做课程设计,也适合刚转 JavaWeb 的开发者搭建第一个有安全意识的用户模块。你需要的不只是会写doPost,而是理解密码怎么存、会话怎么管、参数怎么验、异常怎么兜。下面我按实际落地顺序,把选型、编码、踩坑和验证一步步拆开。
2. 技术选型与数据库设计:别急着写 Servlet
2.1 为什么我建议用 Servlet + JSP + JDBC 而不是一上来就 Spring
很多教程直接上 Spring Boot + MyBatis,对新手来说框架帮你做了太多事,反而看不清登录注册的本质。用原生 Servlet + JSP + JDBC 虽然代码量大一些,但每一步都在你眼皮底下:请求怎么进来、参数怎么取、SQL 怎么拼、会话怎么建。等你把这套跑通了,再换框架就是换个写法的事。
具体选型上,Servlet 用HttpServlet的doGet/doPost,JSP 只负责展示,JDBC 用PreparedStatement防注入。数据库选 MySQL 5.7 或 8.0 都行,字符集用utf8mb4,别用utf8,否则 emoji 存进去变问号。连接池用 Druid 或 HikariCP,别每次请求都DriverManager.getConnection,那样并发一上来连接数就爆了。
2.2 用户表设计的五个关键字段和三个索引
先看建表语句,我一般会这样写:
CREATE TABLE `t_user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(64) NOT NULL COMMENT '登录名,唯一', `password_hash` VARCHAR(255) NOT NULL COMMENT 'BCrypt哈希值,不是明文', `salt` VARCHAR(64) DEFAULT NULL COMMENT '可选,BCrypt自带盐可不用', `email` VARCHAR(128) 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`), KEY `idx_email` (`email`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';逻辑说明:password_hash字段长度给到 255,因为 BCrypt 哈希值固定 60 字符,但留余量方便以后换算法。username加唯一索引,防止并发注册时插入重复数据。status加索引是为了后台按状态筛选用户时走索引。create_time和update_time用数据库默认值,避免应用层忘记赋值。
参数说明:BIGINT UNSIGNED支持到 1844 亿,够用。VARCHAR(64)对用户名来说足够,太长了反而容易被滥用。字符集utf8mb4配合utf8mb4_general_ci排序规则,大小写不敏感,登录时where username = ?能匹配到不同大小写,但注册时要统一转小写存储,避免出现Admin和admin两个账号。
注意:不要用
utf8,它只支持最多 3 字节,emoji 和部分生僻字会丢。也不要用utf8mb4_unicode_ci,性能比general_ci差一截,除非你有特殊排序需求。
2.3 密码存储:BCrypt 是底线,MD5 和 SHA 直接扔
我见过太多项目用 MD5 存密码,甚至加个固定盐。MD5 和 SHA-1 早就被证明不安全,GPU 每秒能算几十亿次,彩虹表一查就出来。正确做法是用 BCrypt 或 Argon2。BCrypt 在 Java 里用org.mindrot.jbcrypt.BCrypt或 Spring Security 的BCryptPasswordEncoder。
import org.mindrot.jbcrypt.BCrypt; public class PasswordUtil { // 生成哈希,cost 因子 12,越高越慢但越安全 public static String hash(String plainPassword) { return BCrypt.hashpw(plainPassword, BCrypt.gensalt(12)); } // 校验,BCrypt.checkpw 内部自动提取盐 public static boolean verify(String plainPassword, String hashed) { if (hashed == null || !hashed.startsWith("$2a$")) { return false; } return BCrypt.checkpw(plainPassword, hashed); } }逻辑说明:gensalt(12)里的 12 是 cost 因子,表示 2 的 12 次方轮迭代,大约 200 毫秒一次哈希。这个值可以根据服务器性能调整,一般 10 到 12 之间。checkpw会自动从哈希值里提取盐,不需要单独存盐字段。startsWith("$2a$")是防御性判断,防止数据库里存了旧格式的哈希导致异常。
参数说明:cost 因子每加 1,计算时间翻倍。线上环境建议 12,开发环境可以 10 加快测试。如果用户量特别大,登录接口的哈希校验会成为瓶颈,这时候可以考虑异步校验或者升级硬件,但不要为了性能降到 8 以下。
3. 注册接口的完整实现与参数校验
3.1 从请求到入库:一个注册 Servlet 的完整代码
注册流程分四步:接收参数、校验参数、检查用户名是否存在、写入数据库。下面是一个可运行的RegisterServlet:
@WebServlet("/register") public class RegisterServlet extends HttpServlet { private final UserService userService = new UserService(); @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"); String email = req.getParameter("email"); // 1. 参数校验 if (username == null || username.trim().length() < 3 || username.trim().length() > 32) { writeJson(resp, 400, "用户名长度需在3到32之间"); return; } if (password == null || password.length() < 8) { writeJson(resp, 400, "密码长度不能少于8位"); return; } if (!username.matches("^[a-zA-Z0-9_]+$")) { writeJson(resp, 400, "用户名只能包含字母、数字和下划线"); return; } // 2. 统一转小写,避免大小写重复 String normalizedUsername = username.trim().toLowerCase(); // 3. 检查是否存在 if (userService.existsByUsername(normalizedUsername)) { writeJson(resp, 409, "用户名已被占用"); return; } // 4. 入库 try { userService.register(normalizedUsername, password, email); writeJson(resp, 200, "注册成功"); } catch (Exception e) { log("注册失败", e); writeJson(resp, 500, "服务器内部错误"); } } private void writeJson(HttpServletResponse resp, int code, String msg) throws IOException { resp.setStatus(code); resp.getWriter().write("{\"code\":" + code + ",\"msg\":\"" + msg + "\"}"); } }逻辑说明:setCharacterEncoding("UTF-8")必须在getParameter之前调用,否则中文参数会乱码。参数校验先做长度再做正则,避免正则处理超长字符串消耗 CPU。normalizedUsername统一转小写,配合数据库唯一索引,能防止Admin和admin同时注册。writeJson手动拼 JSON 是为了不引入额外依赖,生产环境建议用 Jackson 或 Gson。
参数说明:用户名长度 3 到 32 是经验值,太短容易被枚举,太长影响索引性能。密码最少 8 位,不强制复杂度但建议前端提示。邮箱字段可选,但如果有找回密码功能就必须校验格式。
3.2 用户名重复检查的并发陷阱与唯一索引兜底
上面的代码先existsByUsername再register,两步之间有时间窗口。如果两个请求同时用同一个用户名注册,都查到不存在,然后都插入,就会产生重复数据。解决办法是数据库唯一索引兜底:
public void register(String username, String password, String email) { String sql = "INSERT INTO t_user (username, password_hash, email) VALUES (?, ?, ?)"; try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, PasswordUtil.hash(password)); ps.setString(3, email); ps.executeUpdate(); } catch (SQLIntegrityConstraintViolationException e) { // 唯一索引冲突,说明并发下已被注册 throw new BizException("用户名已被占用"); } catch (SQLException e) { throw new RuntimeException("数据库异常", e); } }逻辑说明:SQLIntegrityConstraintViolationException是 MySQL 唯一索引冲突时抛出的异常,捕获它并转成业务异常,前端就能看到友好提示。这样即使并发请求同时通过检查,也只有一个能插入成功。
参数说明:DataSourceUtil.getConnection()从连接池获取连接,用完自动关闭。PreparedStatement的?占位符由驱动处理转义,杜绝 SQL 注入。
3.3 前端校验只是体验,后端校验才是安全
前端用 JavaScript 校验用户名密码格式,能减少无效请求,但攻击者可以直接调接口绕过。所以后端必须重复校验,而且校验规则要更严格。我一般会在后端加这些检查:
- 用户名不能是保留字,比如
admin、root、system,这些容易被撞库。 - 密码不能和用户名相同,也不能是
12345678、password这类弱密码。 - 邮箱格式用正则简单校验,不追求完全符合 RFC,够用就行。
- 请求频率限制,同一 IP 一分钟内注册超过 5 次就拒绝。
// 弱密码黑名单,实际可以放配置文件或数据库 private static final Set<String> WEAK_PASSWORDS = Set.of( "12345678", "password", "qwertyui", "admin123", "11111111" ); if (WEAK_PASSWORDS.contains(password.toLowerCase())) { writeJson(resp, 400, "密码过于简单"); return; }逻辑说明:Set.of是 Java 9 的不可变集合,查重 O(1)。实际项目可以把黑名单放 Redis,支持动态更新。
参数说明:频率限制可以用 Guava RateLimiter 或 Redis 计数器,简单场景用ConcurrentHashMap记录 IP 和时间窗口也行,但要注意内存清理。
4. 登录接口的会话管理与安全加固
4.1 登录校验流程与 Session 固定攻击防御
登录比注册多一步会话建立。很多人直接request.getSession()然后setAttribute,这会有会话固定攻击风险:攻击者先访问网站拿到一个 Session ID,然后诱导你用它登录,之后攻击者就能用同一个 Session ID 访问你的账号。正确做法是登录成功后先invalidate旧会话,再创建新会话:
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); @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 || password == null) { writeJson(resp, 400, "参数缺失"); return; } User user = userService.findByUsername(username.trim().toLowerCase()); if (user == null || !PasswordUtil.verify(password, user.getPasswordHash())) { // 不区分“用户不存在”和“密码错误”,防止用户名枚举 writeJson(resp, 401, "用户名或密码错误"); return; } if (user.getStatus() == 0) { writeJson(resp, 403, "账号已被禁用"); return; } // 防御会话固定:销毁旧会话,创建新会话 HttpSession oldSession = req.getSession(false); if (oldSession != null) { oldSession.invalidate(); } HttpSession session = req.getSession(true); session.setAttribute("userId", user.getId()); session.setAttribute("username", user.getUsername()); session.setMaxInactiveInterval(30 * 60); // 30分钟 writeJson(resp, 200, "登录成功"); } private void writeJson(HttpServletResponse resp, int code, String msg) throws IOException { resp.setStatus(code); resp.getWriter().write("{\"code\":" + code + ",\"msg\":\"" + msg + "\"}"); } }逻辑说明:findByUsername返回 null 或密码校验失败都返回同样的错误信息,避免攻击者通过错误提示判断用户名是否存在。getSession(false)不创建新会话,如果旧会话存在就销毁。getSession(true)创建新会话,旧的 Session ID 立即失效。setMaxInactiveInterval设置 30 分钟超时,根据业务调整。
参数说明:30 * 60是秒数,表示 30 分钟无操作后会话过期。如果系统敏感,可以缩短到 15 分钟。userId存到 Session 里,后续接口从 Session 取,不要从请求参数取,防止越权。
4.2 记住我功能的 Token 方案与过期策略
“记住我”不能简单把用户名密码存 Cookie,那样等于把钥匙挂在门上。常见做法是生成一个随机 Token,存数据库和 Cookie,下次访问时用 Token 换会话。Token 要设置过期时间,并且一次有效:
public String createRememberMeToken(Long userId) { String token = UUID.randomUUID().toString().replace("-", ""); String sql = "INSERT INTO t_remember_token (user_id, token, expire_time) VALUES (?, ?, ?)"; try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, userId); ps.setString(2, token); ps.setTimestamp(3, new Timestamp(System.currentTimeMillis() + 7 * 24 * 3600 * 1000L)); ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("Token创建失败", e); } return token; }逻辑说明:UUID.randomUUID()生成 128 位随机值,碰撞概率极低。replace("-", "")去掉横线,缩短长度。过期时间设 7 天,存数据库方便服务端主动失效。
参数说明:7 * 24 * 3600 * 1000L是 7 天的毫秒数,注意用L防止整数溢出。Token 表要加user_id和token的联合索引,查询时走索引。
4.3 登录失败次数限制与账号锁定
暴力破解是登录接口最常见的攻击。我一般会做两层限制:同一 IP 一分钟最多 10 次登录请求,同一账号连续失败 5 次锁定 15 分钟。用 Redis 的INCR和EXPIRE实现最简单:
public boolean isAccountLocked(String username) { String key = "login:fail:" + username; String count = redis.get(key); return count != null && Integer.parseInt(count) >= 5; } public void recordLoginFailure(String username) { String key = "login:fail:" + username; Long count = redis.incr(key); if (count == 1) { redis.expire(key, 15 * 60); // 15分钟 } } public void clearLoginFailure(String username) { redis.del("login:fail:" + username); }逻辑说明:incr原子递增,第一次失败时设置过期时间。登录成功调用clearLoginFailure清除计数。isAccountLocked在密码校验前调用,锁定直接返回错误。
参数说明:15 * 60是 15 分钟,可以根据安全等级调整。阈值 5 次是经验值,太低容易误伤,太高起不到防护作用。
5. 避坑与排查:那些让我加班到凌晨的细节
5.1 中文乱码:POST 和 GET 处理方式不一样
现象:注册时用户名输入中文,数据库里变成???。原因:POST 请求默认用 ISO-8859-1 解码,GET 请求在 Tomcat 8 以后默认 UTF-8 但 URL 编码方式不同。解决:POST 在getParameter前调req.setCharacterEncoding("UTF-8");GET 在 Tomcat 的server.xml里配URIEncoding="UTF-8",或者用new String(username.getBytes("ISO-8859-1"), "UTF-8")手动转。数据库连接 URL 加useUnicode=true&characterEncoding=utf8mb4。
5.2 连接池耗尽:每次请求都新建连接
现象:并发一上来就报Too many connections。原因:代码里用DriverManager.getConnection每次新建连接,用完没关。解决:用 Druid 或 HikariCP,配置maxActive=20、maxWait=3000,并且用 try-with-resources 确保连接关闭。检查代码里有没有Connection没在 finally 里 close 的情况。
5.3 Session 丢失:重启后用户全部掉线
现象:Tomcat 重启后所有用户需要重新登录。原因:Session 默认存在内存里,重启就没了。解决:开发环境可以接受,生产环境用 Redis 存 Session,或者用 Tomcat 的 Session 持久化配置。如果只是单机,可以在context.xml里配saveOnRestart,但性能有影响。
5.4 密码哈希升级:cost 因子改了旧密码还能用吗
现象:把 BCrypt cost 从 10 改成 12,旧用户登录失败。原因:checkpw只校验哈希值,不关心 cost,旧哈希是用 cost 10 生成的,校验时仍然用旧哈希里的 cost,所以能通过。但如果你换了算法比如从 MD5 换 BCrypt,旧密码就废了。解决:换算法时做兼容,登录时先按旧算法校验,通过后重新用新算法哈希并更新数据库。
5.5 越权访问:改个 URL 就能看别人信息
现象:登录后访问/user/profile?userId=123,把 123 改成 124 就能看到别人资料。原因:接口从请求参数取 userId,没有校验当前登录用户。解决:从 Session 取userId,不要从请求参数取。如果必须传参,校验参数值是否等于 Session 里的值。所有涉及用户数据的接口都要做这个检查。
6. 进阶验证:用脚本压测登录接口并检查安全头
6.1 用 JMeter 或 ab 做并发登录测试
写完代码别急着上线,先压测。用ab命令简单测:
ab -n 1000 -c 50 -p login.json -T application/json http://localhost:8080/loginlogin.json内容:
{"username":"testuser","password":"Test123456"}逻辑说明:-n 1000总请求数,-c 50并发数,-p指定 POST 数据文件,-T指定 Content-Type。观察Requests per second和Failed requests,如果失败率高,检查连接池和数据库索引。
参数说明:并发数从 50 开始,逐步加到 200,看系统瓶颈在哪。如果 CPU 先到 100%,说明 BCrypt 计算太重,可以降 cost 或加机器。如果数据库先到瓶颈,检查username索引是否命中。
6.2 检查响应头:三个必须加的安全头
登录注册接口的响应头要加这几个:
| 响应头 | 值 | 作用 |
|---|---|---|
| X-Content-Type-Options | nosniff | 防止浏览器猜测内容类型 |
| X-Frame-Options | DENY | 防止页面被嵌入 iframe |
| Cache-Control | no-store | 防止登录页面被缓存 |
在 Servlet 里加:
resp.setHeader("X-Content-Type-Options", "nosniff"); resp.setHeader("X-Frame-Options", "DENY"); resp.setHeader("Cache-Control", "no-store");逻辑说明:nosniff防止把 JSON 当 HTML 解析,DENY防止点击劫持,no-store防止敏感页面被浏览器缓存。这些头在 Nginx 层加也行,但应用层加更保险。
6.3 一个我反复用的验证习惯
每次改完登录注册代码,我会做三件事:第一,用错误密码登录 5 次,确认账号被锁定;第二,用 SQL 注入字符串' or '1'='1试登录,确认返回错误;第三,注册一个用户后直接查数据库,确认password_hash字段不是明文。这三步花不了五分钟,但能挡住大部分低级漏洞。希望帮到你。
本文还有配套的精品资源,点击获取