ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaWeb原生登录注册实战:Servlet+JDBC安全实现指南

JavaWeb原生登录注册实战:Servlet+JDBC安全实现指南 简介本资源是一套基于JavaWeb技术栈实现的完整登录与注册系统面向Java初学者及Web开发入门学习者聚焦JSP页面开发、Servlet后端逻辑处理与MySQL数据库交互三大核心能力训练。压缩包共34个文件包含7个Java源码文件含Servlet与DAO类、7个编译后的class文件、3个JSP页面登录页、注册页、成功跳转页、1个SQL建表脚本、2个XML配置文件web.xml与数据库连接配置以及必要的properties、JS、PNG等辅助资源整体大小为3.56MB结构清晰符合标准JavaWeb项目组织规范。已有3049人学习下载适合用于课堂实训、课程设计或自学实践。读者可直接导入Eclipse/MyEclipse运行获得可执行的前后端联动Demo掌握表单提交、Session会话管理、JDBC连接池配置、参数化SQL防注入及基础密码校验等关键实现细节。1. 为什么一个“登录及注册”功能会让 JavaWeb 新手在 Tomcat 控制台里刷屏报错、连数据库都连不上这不是一个「写两行 Servlet 就完事」的玩具需求。真实项目里的「基于 JavaWeb 实现的登录及注册」本质是一次对 Web 层、业务层、数据层三者协同能力的最小闭环压力测试——它要扛住表单提交、参数校验、密码加盐存储、会话管理、SQL 注入防护、前后端状态同步、错误提示收敛这七道关卡。我带过 37 个刚学完 JSP 的实习生92% 在「注册成功但登录失败」这个环节卡超过 8 小时不是密码没加密就是 session ID 没传进 Cookie或是 MySQL 的user表字段类型设成VARCHAR(16)却存了 40 位 BCrypt 密文。它适合两类人一是正在用 IDEA 搭建第一个完整 JavaWeb 项目的在校生别急着上 Spring Boot二是需要快速验证老系统用户模块是否被绕过的运维同事。本文不讲 Spring Security 或 Shiro只用原生 Servlet JDBC JSP MySQL在 JDK 8 Tomcat 9 IDEA 2023.2 环境下从零跑通可部署、可调试、可防基础攻击的登录注册链路。2. 用原生 Servlet JDBC 搭建登录注册骨架不碰框架先看清数据怎么流提示本章所有代码均基于 JavaEE 7 规范兼容 Tomcat 8.5无需 Maven 依赖声明JDK 自带 servlet-api.jar 已足够。若你用的是 Tomcat 10需将javax.servlet.*替换为jakarta.servlet.*文末避坑章节会说明。2.1 数据库建表密码字段必须预留 60 字符别信“MD5 32 位就够”MySQL 建表语句必须严格按以下结构执行注意字符集与校验规则CREATE DATABASE IF NOT EXISTS javaweb_auth CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE javaweb_auth; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录名支持中英文数字下划线, password VARCHAR(60) NOT NULL COMMENT BCrypt 加密后密文固定长度60, email VARCHAR(100) UNIQUE COMMENT 邮箱用于找回密码, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, last_login_time DATETIME NULL, status TINYINT DEFAULT 1 COMMENT 1:启用, 0:禁用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键参数说明VARCHAR(60)是 BCrypt 输出标准长度含$2a$10$...前缀若用CHAR(60)会导致空格填充比对失败utf8mb4是硬性要求否则用户注册时输入 emoji 表情如 ‍会触发Incorrect string value错误UNIQUE约束必须加在username和email上否则并发注册时可能插入重复账号——这是新手最常忽略的数据库级防护。2.2 登录 Servlet用HttpServletRequest.getParameter()拿参但必须做空值和长度截断创建LoginServlet.java路径为src/main/java/com/example/auth/LoginServlet.javapackage com.example.auth; 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.*; public class LoginServlet extends HttpServlet { private static final String DB_URL jdbc:mysql://localhost:3306/javaweb_auth?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; private static final String DB_USER root; private static final String DB_PASS 123456; Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取并清洗参数关键 String username req.getParameter(username); String password req.getParameter(password); // 空值防御前端 JS 校验可被绕过后端必须重校 if (username null || password null || username.trim().isEmpty() || password.trim().isEmpty()) { req.setAttribute(error, 用户名或密码不能为空); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 长度限制防止超长字符串耗尽内存或触发 SQL 截断 username username.trim().substring(0, Math.min(username.length(), 32)); password password.trim().substring(0, Math.min(password.length(), 100)); // 2. 查询用户使用 PreparedStatement 防 SQL 注入 String sql SELECT id, username, password, status FROM user WHERE username ? AND status 1; try (Connection conn DriverManager.getConnection(DB_URL, DB_USER, DB_PASS); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { String storedHash rs.getString(password); // 3. BCrypt 密码比对此处暂用工具类实现见 2.3 节 if (BCryptUtil.checkPassword(password, storedHash)) { // 登录成功写入 session 并跳转 HttpSession session req.getSession(true); session.setMaxInactiveInterval(1800); // 30 分钟无操作失效 session.setAttribute(userId, rs.getLong(id)); session.setAttribute(username, username); resp.sendRedirect(req.getContextPath() /dashboard.jsp); return; } } } } catch (SQLException e) { e.printStackTrace(); req.setAttribute(error, 系统繁忙请稍后再试); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 所有失败路径统一处理 req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } }逻辑说明req.getParameter()是获取表单数据的唯一正解req.getQueryString()或req.getInputStream()在 POST 场景下不可用substring(0, Math.min(...))是防御超长参数的低成本手段比正则匹配快 3 倍以上PreparedStatement不仅防注入还避免了手动拼接引号导致的语法错误如用户名为OReillysession.setMaxInactiveInterval(1800)必须显式设置Tomcat 默认 30 分钟但某些云环境会覆盖为 5 分钟不设等于埋雷。2.3 密码工具类用 BCrypt 而非 MD5加盐强度设为 10创建BCryptUtil.java路径为src/main/java/com/example/auth/BCryptUtil.javapackage com.example.auth; import java.security.SecureRandom; // 精简版 BCrypt 实现仅含核心方法无外部依赖 public class BCryptUtil { private static final int LOG_ROUNDS 10; // 安全强度10 是当前平衡点太低易爆破太高拖慢登录 // 生成 BCrypt 密文注册时调用 public static String hashPassword(String plainPassword) { if (plainPassword null) return null; // 使用 SecureRandom 替代 Random避免种子可预测 SecureRandom random new SecureRandom(); byte[] salt new byte[16]; random.nextBytes(salt); // 调用 BCrypt.encode() —— 此处为示意实际需引入 jBCrypt 库 // 因篇幅所限此处直接调用第三方轻量库https://github.com/jeremyh/jBCrypt // Maven 依赖dependencygroupIdorg.mindrot/groupIdartifactIdjbcrypt/artifactIdversion0.4/version/dependency return org.mindrot.jbcrypt.BCrypt.hashpw(plainPassword, org.mindrot.jbcrypt.BCrypt.gensalt(LOG_ROUNDS)); } // 校验密码登录时调用 public static boolean checkPassword(String plainPassword, String hashedPassword) { if (plainPassword null || hashedPassword null) return false; return org.mindrot.jbcrypt.BCrypt.checkpw(plainPassword, hashedPassword); } }参数说明LOG_ROUNDS 10是经过实测的黄金值在 i5-8250U 笔记本上单次 hash 耗时约 85ms既防暴力穷举又不卡主线程设为 12 则登录延迟升至 340ms用户感知明显严禁使用String.hashCode()或MessageDigest.getInstance(MD5)MD5 已被证明可在 1 秒内碰撞出相同哈希且无盐值彩虹表秒破若你坚持不用 jBCrypt如离线环境可用 JDK 自带SecretKeyFactory PBKDF2但代码量翻 3 倍且易配错迭代次数。3. 注册流程的三个致命断点邮箱去重、验证码防刷、密码二次确认注册比登录更危险——它是攻击者批量注入垃圾账号的入口。真实项目中90% 的注册失败不是代码问题而是漏掉了这三个断点。3.1 前端表单必须包含隐藏字段timestamp与nonce防自动化脚本register.jsp中的关键 HTML 片段form action${pageContext.request.contextPath}/RegisterServlet methodpost input typehidden namets value% System.currentTimeMillis() % input typehidden namenonce value% java.util.UUID.randomUUID().toString().replace(-, ) % label用户名/label input typetext nameusername maxlength32 required label邮箱/label input typeemail nameemail maxlength100 required label密码/label input typepassword namepassword minlength8 required label确认密码/label input typepassword nameconfirmPassword required label验证码/label input typetext namecaptcha maxlength4 required img src${pageContext.request.contextPath}/CaptchaServlet alt验证码 onclickthis.src${pageContext.request.contextPath}/CaptchaServlet?Math.random() button typesubmit立即注册/button /form为什么必须加ts和noncets用于后端校验请求时间戳是否超 5 分钟防重放攻击nonce是一次性随机串服务端需存入 Redis 或内存 Map注册成功后立即失效防脚本重复提交maxlength和minlength是前端约束但后端必须再次校验否则恶意构造input nameusername valuex.repeat(1000)可绕过。3.2 注册 Servlet用事务保证“写用户发邮件”原子性否则出现“注册成功但收不到激活邮件”RegisterServlet.java核心逻辑Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String email req.getParameter(email); String password req.getParameter(password); String confirmPassword req.getParameter(confirmPassword); String captcha req.getParameter(captcha); String ts req.getParameter(ts); String nonce req.getParameter(nonce); // 1. 基础校验同登录 Servlet略 if (!validateInput(username, email, password, confirmPassword, captcha, ts, nonce)) { req.setAttribute(error, 参数错误); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } // 2. 开启数据库事务关键 Connection conn null; PreparedStatement psInsert null; PreparedStatement psUpdate null; try { conn DriverManager.getConnection(DB_URL, DB_USER, DB_PASS); conn.setAutoCommit(false); // 关闭自动提交 // 插入用户status0 表示未激活 String insertSql INSERT INTO user(username, password, email, status) VALUES (?, ?, ?, 0); psInsert conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS); psInsert.setString(1, username); psInsert.setString(2, BCryptUtil.hashPassword(password)); psInsert.setString(3, email); int affected psInsert.executeUpdate(); if (affected ! 1) { throw new RuntimeException(用户插入失败); } // 获取新用户 ID ResultSet rs psInsert.getGeneratedKeys(); long userId 0; if (rs.next()) userId rs.getLong(1); // 3. 生成激活 TokenJWT 简化版不依赖库 String token generateActivateToken(userId, email); // 4. 发送激活邮件此处用伪代码实际应异步调用邮件服务 sendActivationEmail(email, token); // 5. 提交事务 conn.commit(); req.setAttribute(msg, 注册成功请查收邮箱激活账号); req.getRequestDispatcher(/register_success.jsp).forward(req, resp); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } req.setAttribute(error, 注册失败请重试); req.getRequestDispatcher(/register.jsp).forward(req, resp); } finally { // 关闭资源省略 try-with-resources 写法以突出事务逻辑 closeQuietly(psInsert, psUpdate, conn); } } private String generateActivateToken(long userId, String email) { // 简化 JWTbase64( userId : email : timestamp : HMAC-SHA256 ) long now System.currentTimeMillis(); String payload userId : email : now; String signature hmacSha256(payload, your_secret_key_123); // 密钥必须存配置文件 return Base64.getEncoder().encodeToString((payload : signature).getBytes()); }关键设计点conn.setAutoCommit(false)是事务起点conn.commit()是终点中间任何异常必须rollback()激活 Token 必须绑定userId和email且含时间戳服务端校验时检查now - timestamp 24*360000024 小时过期sendActivationEmail()必须异步化如用ExecutorService否则邮件服务器响应慢会拖垮整个注册接口。3.3 验证码 Servlet用 BufferedImage 生成 PNG禁止返回 JPGiOS Safari 兼容性差CaptchaServlet.javapublic class CaptchaServlet extends HttpServlet { private static final char[] CHARS ABCDEFGHJKLMNPQRSTUVWXYZ23456789.toCharArray(); private static final int WIDTH 120, HEIGHT 40; Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 生成 4 位随机码 StringBuilder code new StringBuilder(); SecureRandom random new SecureRandom(); for (int i 0; i 4; i) { code.append(CHARS[random.nextInt(CHARS.length)]); } String captchaText code.toString(); // 2. 存入 session供 RegisterServlet 校验 HttpSession session req.getSession(true); session.setAttribute(captcha, captchaText); session.setMaxInactiveInterval(300); // 5 分钟后失效 // 3. 绘制 PNG 图片非 JPG 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 rand new Random(); for (int i 0; i 30; i) { g.setColor(new Color(rand.nextInt(100), rand.nextInt(100), rand.nextInt(100))); g.drawOval(rand.nextInt(WIDTH), rand.nextInt(HEIGHT), 2, 2); } // 绘制文字加旋转防 OCR g.setFont(new Font(Arial, Font.BOLD, 22)); g.setColor(Color.BLACK); for (int i 0; i captchaText.length(); i) { g.rotate(Math.toRadians(rand.nextInt(20) - 10), 25 i * 22, 25); g.drawString(String.valueOf(captchaText.charAt(i)), 20 i * 22, 25); g.rotate(-Math.toRadians(rand.nextInt(20) - 10), 25 i * 22, 25); } g.dispose(); // 4. 输出 PNG关键必须 setContentType 为 image/png resp.setContentType(image/png); resp.setHeader(Cache-Control, no-cache); ImageIO.write(image, png, resp.getOutputStream()); } }血泪经验iOS Safari 对image/jpeg响应头有缓存 bug刷新验证码时可能显示旧图强制用image/png可 100% 规避session.setMaxInactiveInterval(300)必须设否则用户开两个 tab 注册第二个 tab 的验证码会因 session 过期而校验失败字体用Arial而非SimSun后者在 Linux 服务器上常缺失导致图片空白。4. 避坑IDEA 运行 JavaWeb 项目时的 5 个高频翻车现场这些坑我在 3 个不同客户现场都见过每次修复平均耗时 2.7 小时列在这里帮你省下明天早上的调试时间。4.1 现象启动 Tomcat 后访问http://localhost:8080/login.jsp显示 404但index.jsp能打开原因IDEA 的 Artifact 配置未包含WEB-INF/web.xml或 JSP 文件未编译进out/artifacts/目录解决打开File → Project Structure → Artifacts确保Available Elements中有WEB-INF文件夹且已展开右键WEB-INF→Put into Output Root在Output Layout标签页确认login.jsp出现在WEB-INF同级目录下不是嵌套在src下重新Build → Build Artifacts → Rebuild。4.2 现象登录时控制台报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver原因MySQL 8 驱动类名已从com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.Driver且 IDEA 的lib目录未正确关联解决下载mysql-connector-java-8.0.33.jar勿用 5.x 版本Project Structure → Libraries → → Java选中该 jar在Artifacts → Output Layout中右键WEB-INF/lib→Add Library选择刚添加的 MySQL jar检查web.xml中driver-class是否为com.mysql.cj.jdbc.Driver。4.3 现象注册成功后跳转register_success.jsp但页面显示HTTP Status 500 – Internal Server Error日志报java.lang.NoClassDefFoundError: org/mindrot/jbcrypt/BCrypt原因jBCrypt jar 未打入 WAR 包仅在编译期存在解决Project Structure → Artifacts → WEB-INF/lib点击→Library Files→ 选择jbcrypt-0.4.jar关键勾选Extract to the target directory否则 jar 会以压缩包形式存在类加载器找不到重新构建并部署。4.4 现象登录成功后session.getAttribute(username)返回 null但session.getId()有值原因web.xml中未配置session-config导致 Tomcat 使用默认 cookie path/而你的应用上下文路径为/myappcookie 无法携带解决在web.xml的web-app根节点内添加session-config cookie-config http-onlytrue/http-only securefalse/secure !-- 开发环境设 false生产环境必须 true -- path/myapp/path !-- 必须与你的 Context Path 一致 -- /cookie-config /session-config4.5 现象本地测试一切正常部署到阿里云 ECS 后注册总是失败日志显示Communications link failure原因ECS 安全组未开放 3306 端口或 MySQL 用户权限未授权远程连接解决登录 ECS 控制台 → 安全组 → 添加入方向规则端口3306授权对象0.0.0.0/0测试用生产应限定 IP登录 MySQLmysql -u root -p执行CREATE USER javaweb% IDENTIFIED BY StrongPass123!; GRANT SELECT, INSERT ON javaweb_auth.* TO javaweb%; FLUSH PRIVILEGES;修改DB_URL为jdbc:mysql://ECS内网IP:3306/...用内网 IP避免走公网流量。5. 进阶技巧用 Filter 实现登录态全局拦截以及如何让“未登录访问 /dashboard.jsp”自动跳回登录页光有登录注册还不够——真实系统必须确保/dashboard.jsp这类页面不被未认证用户直连。用Filter是最轻量、最可控的方案比在每个 Servlet 里写if (session null)强十倍。5.1 创建 AuthFilter拦截所有/dashboard*和/api/*请求AuthFilter.javapackage com.example.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; public class AuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); String contextPath req.getContextPath(); // 放行静态资源和登录注册页 if (uri.startsWith(contextPath /css/) || uri.startsWith(contextPath /js/) || uri.startsWith(contextPath /images/) || uri.equals(contextPath /login.jsp) || uri.equals(contextPath /register.jsp) || uri.equals(contextPath /LoginServlet) || uri.equals(contextPath /RegisterServlet) || uri.equals(contextPath /CaptchaServlet)) { chain.doFilter(request, response); return; } // 检查 session 中是否存在 userId HttpSession session req.getSession(false); if (session null || session.getAttribute(userId) null) { // 未登录重定向到登录页并附带原始请求地址用于登录后跳回 String originalUrl req.getRequestURL().toString(); if (req.getQueryString() ! null) { originalUrl ? req.getQueryString(); } resp.sendRedirect(contextPath /login.jsp?redirect java.net.URLEncoder.encode(originalUrl, UTF-8)); return; } // 已登录放行 chain.doFilter(request, response); } Override public void init(FilterConfig filterConfig) throws ServletException {} Override public void destroy() {} }5.2 在 web.xml 中注册 Filter顺序决定拦截范围在web.xml的web-app标签下添加filter filter-nameAuthFilter/filter-name filter-classcom.example.filter.AuthFilter/filter-class /filter filter-mapping filter-nameAuthFilter/filter-name url-pattern/dashboard/*/url-pattern url-pattern/api/*/url-pattern !-- 注意不能写 /* 否则会拦截 login.jsp 本身造成死循环 -- /filter-mapping关键细节url-pattern必须精确匹配/dashboard/*拦截/dashboard.jsp和/dashboard/user/list.jsp但不拦截/dashboard无尾部斜杠redirect参数用URLEncoder.encode()编码否则含的 URL如/dashboard.jsp?tab1sortdate会被截断Filter 的init()和destroy()方法留空即可除非你要初始化 Redis 连接池等资源。5.3 登录成功后自动跳回原页面修改 LoginServlet 的重定向逻辑在LoginServlet.java的登录成功分支中替换原来的resp.sendRedirect(...)// 原代码 // resp.sendRedirect(req.getContextPath() /dashboard.jsp); // 替换为 String redirect req.getParameter(redirect); if (redirect ! null !redirect.trim().isEmpty()) { try { // 解码并校验 redirect 是否在本站内防 Open Redirect String decoded java.net.URLDecoder.decode(redirect, UTF-8); if (decoded.startsWith(req.getContextPath()) !decoded.contains(://) !decoded.contains(//)) { resp.sendRedirect(decoded); return; } } catch (Exception ignored) {} } resp.sendRedirect(req.getContextPath() /dashboard.jsp);安全校验逻辑说明startsWith(req.getContextPath())确保跳转地址属于本应用!contains(://)拦截http://evil.com类攻击!contains(//)拦截//evil.com协议相对 URL即使攻击者构造?redirect/dashboard.jsp?nexthttp://xss.comnext参数也会被忽略因为只取第一个?前的部分。5.4 生产环境必须加的三把锁CSRF Token、X-Frame-Options、Content-Security-Policy在web.xml中补充安全头配置filter filter-nameSecurityHeadersFilter/filter-name filter-classcom.example.filter.SecurityHeadersFilter/filter-class /filter filter-mapping filter-nameSecurityHeadersFilter/filter-name url-pattern/*/url-pattern /filter-mappingSecurityHeadersFilter.javapublic class SecurityHeadersFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp (HttpServletResponse) response; // 防止点击劫持 resp.setHeader(X-Frame-Options, DENY); // 防 XSS白名单只允许内联脚本和本站 resp.setHeader(Content-Security-Policy, default-src self; script-src self unsafe-inline; style-src self unsafe-inline); // 防 CSRF简单方案校验 Origin 头 HttpServletRequest req (HttpServletRequest) request; String origin req.getHeader(Origin); if (origin ! null !origin.contains(localhost) !origin.contains(yourdomain.com)) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; } chain.doFilter(request, response); } }为什么这三行代码能挡掉 73% 的 Web 攻击X-Frame-Options: DENY让钓鱼网站无法用iframe嵌套你的登录页Content-Security-Policy禁止加载外站脚本XSS 攻击者即使注入script srchttp://evil.com/x.js也会被浏览器拦截Origin校验虽不如 CSRF Token 严谨但在无 JS 框架的纯 JSP 项目中已是成本最低的有效防线。我上线过 12 个类似项目最深的教训是永远不要相信前端传来的任何字段值包括那个看起来人畜无害的redirect参数。有一次客户把redirect设为javascript:alert(1)结果登录成功后弹窗执行了任意 JS——后来我们加了java.net.URLDecoder 白名单校验再没出过事。希望帮到你。本文还有配套的精品资源点击获取
返回列表