
简介一份面向JavaWeb初学者的登录注册页面完整代码示例涵盖JSP前端表单与Servlet后端处理逻辑适合学习Web基础认证流程的开发者参考。资源仅含1个PDF文件压缩包大小797KB集中展示了index.jsp、LoginServlet、RegisterServlet以及success.jsp、failure.jsp等关键代码片段方便对照阅读。该资源已有2380人学习浏览适合作为入门阶段的手把手范例。示例演示了用户通过表单提交用户名和密码、Servlet获取参数并进行简单校验、成功或失败后重定向对应页面的完整过程同时给出了RegisterServlet中扩展验证的思路如检查用户名是否已存在、密码最小长度与复杂度等并提醒实际项目还需引入数据库持久化、SQL注入防护、密码哈希加密等安全措施帮助读者建立从demo到真实项目的进阶认识。1. 这套 JavaWeb 登录注册代码能跑通就是入门最快的路径做 JavaWeb 开发绕不开登录注册这几乎是每个初学者第一个要动手写的完整功能。这套代码示例把 Servlet JSP 的请求处理链路完整串了起来index.jsp提供登录和注册两个表单LoginServlet和RegisterServlet分别处理 POST 请求成功重定向到success.jsp失败重定向到failure.jsp代码量不大但五脏俱全。对刚学完 Servlet 理论、想在 IDEA 里跑通第一个 Web 项目的人来说它是很好的起步素材对已经写过业务代码的开发者则可以把它当成一个检查自己基础是否扎实的对照样本。不过先说清楚这套代码属于教学演示级别离生产环境还差得远硬编码的账号密码、明文传输、没有数据库持久化这些都是在后续开发中必须替换掉的。2. 表单提交与 Servlet 映射先搞懂请求是怎么走到 doPost 的2.1 index.jsp 里两个表单的关键差异index.jsp是整套代码的入口页面里同时放了登录和注册两个表单它们都通过 POST 方法提交但action指向不同的路径登录表单提交到login注册表单提交到register。这个设计很直观两个表单共用同一个页面但请求交给不同的 Servlet 处理避免了在同一个 Servlet 里用分支判断去区分业务逻辑。表单里的required属性值得注意它是 HTML5 原生的非空校验在浏览器端就会阻止空表单提交能在到达服务器之前拦截掉一部分无效请求。但要注意这只是前端体验层面的校验不能作为安全手段——请求完全可以绕过浏览器直接构造所以 Servlet 端必须再做一次参数校验这也就是后面RegisterServlet里重新检查username和password是否为 null 的原因。来看index.jsp的核心代码结构h2Login/h2 form actionlogin methodpost label forusernameUsername:/label input typetext idusername nameusername requiredbrbr label forpasswordPassword:/label input typepassword idpassword namepassword requiredbrbr input typesubmit valueLogin /form h2Register/h2 form actionregister methodpost label forusernameUsername:/label input typetext idusername nameusername requiredbrbr label forpasswordPassword:/label input typepassword idpassword namepassword requiredbrbr input typesubmit valueRegister /form两个表单的字段名都叫username和password这意味着两个 Servlet 里通过request.getParameter(username)取值的代码可以完全一致。但更推荐的实践是给注册表单额外加上确认密码、邮箱等字段在后续章节里我会给出扩展思路。这里还有一个细节input typepassword只是让输入内容在界面上显示为圆点数据本身还是明文传输的所以 HTTPS 在生产环境是刚需。2.2 Servlet 映射web.xml 配置与注解两种方式的取舍表单提交到login和register路径之后需要让 Servlet 能接住这个请求这一步依赖映射配置。老项目通常在web.xml里写servlet-mapping新项目更推荐用WebServlet注解代码量少、就近维护。WebServlet(/login) public class LoginServlet extends HttpServlet { // doPost 方法实现 }WebServlet(/login)的含义是把LoginServlet绑定到/login这个访问路径上当浏览器或表单提交login请求时Tomcat 就会调用这个类的doPost方法。要注意这里的路径是 URL 匹配模式不是文件名所以不需要写.jsp后缀。如果走web.xml老方式配置长这样servlet servlet-nameLoginServlet/servlet-name servlet-classcom.example.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping两种方式本质一样但有几个容易翻车的区别注解方式下类名变了映射就会失效而web.xml方式下如果servlet-class写错包名Tomcat 启动时就会报ClassNotFoundException。另外注意同一个 URL 不能同时被注解和web.xml映射否则容器会报冲突启动阶段直接失败。2.3 doGet 与 doPost表单声明 methodpost 后为什么还要处理 doGetLoginServlet只重写了doPost方法这在表单提交场景下没问题。但有一个常见情况会踩坑用户直接在浏览器地址栏输入http://localhost:8080/your-app/login这触发的是一次 GET 请求而父类HttpServlet的doGet默认实现是返回 405 错误。页面会显示 HTTP method GET is not supported by this URL不懂的人会以为是代码写崩了。实际开发中有个约定俗成的做法是同时重写doGet和doPost让它们调用同一个处理逻辑protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 核心逻辑 }这样即使有人用 GET 方式访问也能走同一套处理逻辑不会出现 405。但要注意如果你把 GET 请求也交给登录逻辑处理敏感信息会出现在 URL 参数里虽然我们现在的逻辑不依赖 URL 参数但这是个坏习惯所以更严谨的做法是只允许 POSTGET 请求直接重定向回登录页。3. LoginServlet 用户认证从硬编码账号到可扩展的校验逻辑3.1 原始实现的局限性与改进方向LoginServlet的原始代码非常简单把用户提交的用户名密码直接和硬编码的admin/admin比较相同就跳转success.jsp不同就跳转failure.jsp。这段代码能跑通流程但有两个明显问题一是账号密码写死在代码里每次改密码都要改代码重新编译二是username.equals(admin)这句代码如果username是 null会直接抛出NullPointerException。原因是request.getParameter(username)在表单字段不存在或者请求里没有携带该参数时返回 null此时调用null.equals(admin)就炸了。虽然 HTML 的required属性挡得住正常用户的空提交但抓包工具构造的请求根本不带这个字段所以代码层面必须防御。改进后的登录校验逻辑protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); if (username null || password null || username.trim().isEmpty()) { response.sendRedirect(login.jsp?error1); return; } // 生产环境这里应该改为查数据库比如从 user 表查记录 if (admin.equals(username) admin.equals(password)) { HttpSession session request.getSession(); session.setAttribute(username, username); response.sendRedirect(success.jsp); } else { response.sendRedirect(failure.jsp?messageusername or password incorrect); } }这里把admin.equals(username)写成常量在前、变量在后是 Java 开发者防御 NPE 的经典写法因为常量不是 nullequals 永远不会抛空指针。username.trim().isEmpty()用来过滤掉全空格这种看起来像有内容实际是空串的情况。做完这些基础防御之后认证逻辑依然没有真正落地因为它还在和写死的字符串比较。3.2 认证流程设计从 Servlet 到 Service 再到 DAO 的三层划分当登录逻辑要接数据库时就不能在 Servlet 里直接写 JDBC 代码了否则doPost方法会膨胀到无法维护。常见的分层做法是 Servlet 只负责取参、调服务、做跳转真正的业务逻辑放在 Service 层数据库操作放在 DAO 层。以登录为例Servlet 拿到参数后调用userService.login(username, password)Service 内部调userDao.findByUsername(username)拿到用户记录后比对密码哈希。public class LoginServlet extends HttpServlet { private UserService userService new UserService(); protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); User user userService.login(username, password); if (user ! null) { request.getSession().setAttribute(user, user); response.sendRedirect(success.jsp); } else { response.sendRedirect(failure.jsp?messagebad credentials); } } }UserService里要做的三件事校验参数合法性、调用 DAO 查询用户、比对加密后的密码。任何一步失败都返回 null 或者抛出业务异常Servlet 层不关心具体是用户不存在还是密码错误统一走失败跳转。从安全角度讲这种模糊处理反而更好因为它不会泄露账号是否存在的信息避免攻击者通过用户不存在的提示批量探测有效账号。这套代码作为一个教学示例没有引入这些分层但我建议你在自己的项目里至少把 DAO 单独拆出来。原因很简单一旦数据库表结构变了只需要改动 DAO 层Servlet 和 JSP 完全不受影响。后面注册功能接入数据库之后你会发现这个拆分几乎是必然的。4. RegisterServlet 注册校验空值、重名、密码复杂度三层把关4.1 参数合法性检查与错误信息传递RegisterServlet的原始代码只检查了username和password是否为空这种力度显然不够。在实际注册场景中至少要过三道关非空校验、用户名唯一性校验、密码复杂度校验。先看第一道关怎么加固protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String password request.getParameter(password); if (username null || password null || username.trim().isEmpty() || password.trim().isEmpty()) { response.sendRedirect(failure.jsp?message URLEncoder.encode(请填写完整的用户名和密码, UTF-8)); return; } // 第二道关用户名唯一性校验 if (usernameExists(username)) { response.sendRedirect(failure.jsp?message URLEncoder.encode(用户名已存在, UTF-8)); return; } // 第三道关密码复杂度校验 if (!passwordMeetsCriteria(password)) { response.sendRedirect(failure.jsp?message URLEncoder.encode(密码长度至少8位且包含大小写字母和数字, UTF-8)); return; } // 到这里说明校验全部通过后续执行注册逻辑 response.sendRedirect(success.jsp); }这里有一个容易被忽略的技术点跳转 URL 里带中文参数必须做 URL 编码否则浏览器解析会乱码甚至某些容器直接报 400 错误。我用URLEncoder.encode(message, UTF-8)把错误信息编码后再拼到 URL 里。这个做法的另一种替代方案是用request.setAttribute(message, message)配合request.getRequestDispatcher(failure.jsp).forward(...)转发这样中文参数不需要做 URL 编码而且浏览器地址栏不会暴露错误信息。两者的区别在于sendRedirect是重定向浏览器会发起第二次请求URL 会变forward是服务器内部转发URL 不变。在这个场景下我更推荐 forward因为失败信息不需要被用户分享或收藏留在服务器内部处理更干净。4.2 密码复杂度校验的逐字符遍历实现密码复杂度校验是注册功能的核心逻辑前文代码已经展示过一个逐字符遍历的实现。拆开看一下它的逻辑private boolean passwordMeetsCriteria(String password) { if (password.length() 8) { return false; } boolean hasUppercase false; boolean hasLowercase false; boolean hasDigit false; for (char c : password.toCharArray()) { if (Character.isUpperCase(c)) { hasUppercase true; } else if (Character.isLowerCase(c)) { hasLowercase true; } else if (Character.isDigit(c)) { hasDigit true; } } return hasUppercase hasLowercase hasDigit; }Character.isUpperCase、isLowerCase、isDigit三个方法分别判断字符类型只要三类字符都出现过校验就通过。这个实现没有检查特殊符号也没有限制最大长度属于基础规则。如果你想加特殊字符要求加一个布尔变量在else分支里判断!Character.isLetterOrDigit(c)即可。我在实际项目中一般会把这套规则做成可配置的用一个常量类统一管理public class PasswordPolicy { public static final int MIN_LENGTH 8; public static final int MAX_LENGTH 20; public static final boolean REQUIRE_SPECIAL_CHAR true; public static final boolean REQUIRE_UPPERCASE true; public static final boolean REQUIRE_DIGIT true; }这样改策略不用翻业务代码改常量就行。说到底密码校验的目的是提高账号被暴力破解的成本规则越严越安全但也要考虑用户输入的挫败感所以上限长度建议别卡太死20 位足够。4.3 用户名唯一性检查的 DAO 占位逻辑与后续接库方案示例代码里usernameExists方法返回的是false占位逻辑注释写着 Placeholder logic这意味着目前所有用户名都会被判定为不存在。在演示场景下效果就是任何人都能注册成功但接上数据库之后这个方法的行为就完全不同了。private boolean usernameExists(String username) { Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); String sql SELECT id FROM user WHERE username ?; ps conn.prepareStatement(sql); ps.setString(1, username); rs ps.executeQuery(); return rs.next(); // 有记录说明已存在 } catch (SQLException e) { e.printStackTrace(); return false; // 数据库异常时保守放行让注册逻辑继续走 } finally { DBUtil.close(conn, ps, rs); } }这里必须要用PreparedStatement而不是拼 SQL 字符串原因不多说了SQL 注入是最经典的 Web 漏洞。?占位符配合setString把用户输入当作参数传给数据库而不是拼进 SQL 语句里从根上切断了注入路径。占位逻辑里还有一个小细节用户名校验和注册插入之间天然存在竞态条件——两个请求同时检查同一个用户名都不存在然后同时插入最终数据库里可能出现两条同名的记录。解决这个问题必须依靠数据库层面的唯一索引约束在user表的username字段上建UNIQUE索引插入时捕获DuplicateKeyException再返回用户名已存在这才是唯一可靠的方案。Java 代码里再小心也防不住并发。5. 密码加密选型为什么 SHA-256 只算及格BCrypt 才适合生产5.1 SHA-256 加密的代码实现与局限性代码示例里给出了两种密码加密思路一种是用 Java 标准库的MessageDigest实现 SHA-256另一种是用 Spring Security 的BCryptPasswordEncoder。先看 SHA-256 实现private String encryptPassword(String password) { try { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] hash md.digest(password.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : hash) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(SHA-256 algorithm not available, e); } }这段代码的逻辑是通过MessageDigest.getInstance(SHA-256)拿到算法实例把密码字符串转成 UTF-8 字节数组喂进去digest方法返回 32 字节的哈希值最后遍历字节数组用String.format(%02x, b)格式化成十六进制字符串。这里%02x表示两位小写十六进制不足两位补零确保每个字节固定输出两位避免哈希值长度不一致。但 SHA-256 有个致命弱点它是快速哈希算法计算速度极快攻击者可以用 GPU 集群每秒跑几十亿次。用户密码普遍存在规律性一旦数据库泄露彩虹表加暴力破解很快就能还原出弱密码。所以 SHA-256 只能算及格离安全存储密码的要求还有距离。5.2 BCrypt 接入方式强哈希加盐的核心设计BCrypt 的设计目标恰好是解决 SHA-256 的问题它内置随机盐值、迭代成本可调每次加密相同的密码都会产生不同的哈希结果而且计算速度被刻意设计得很慢让暴力破解的成本指数级上升。示例代码里用的是 Spring Security 的BCryptPasswordEncoderBCryptPasswordEncoder passwordEncoder new BCryptPasswordEncoder(); String encodedPassword passwordEncoder.encode(password); // 存库时保存 encodedPassword登录比对时调用 matches 方法BCryptPasswordEncoder默认强度是 10代表 2 的 10 次方轮迭代你可以根据服务器性能调大或调小。调大更安全但响应变慢调小反之。我在项目里一般设 10 到 12这个区间在普通服务器上单次加密耗时约 50 到 100 毫秒用户感知不明显但已经足够拖慢批量破解。如果不想引入 Spring Secure 依赖可以用jBCrypt这个轻量库核心代码如下import org.mindrot.jbcrypt.BCrypt; // 注册时生成哈希 String hashed BCrypt.hashpw(password, BCrypt.gensalt(12)); // 登录时校验 boolean matched BCrypt.checkpw(password, storedHash);这里的gensalt(12)生成一个带成本因子的随机盐hashpw把密码和盐合成最终哈希。校验时checkpw会从storedHash里自动提取盐和成本因子不需要你额外存储盐值这是 BCrypt 比SHA-256 手动加盐方便非常多的地方。顺带说一句MD5 千万不要用。网上有很多 MD5 加密的教程但它的碰撞攻击和彩虹表都已经非常成熟拿 MD5 存密码基本等于裸奔。如果项目里已经有 MD5 存量数据建议在登录校验时做成先验证 MD5 再自动升级为 BCrypt逐步迁移。5.3 密码加密方案对比与选型建议把常见方案放在一起对比选型思路会更清晰方案是否加盐计算速度抗暴力破解建议MD5否极快极差禁止使用SHA-256手动加盐快差不推荐已有系统可临时用SHA-256 服务端加盐是快一般勉强可用BCrypt内置随机盐慢可控强优先推荐PBKDF2内置盐慢可控强合规场景可选选择加密方案的决策点只有一个数据库泄露后攻击者拿到哈希值反推明文的成本有多高。MD5 和 SHA-256 在 GPU 算力面前几乎不设防BCrypt 和 PBKDF2 因为有成本参数控制可以有效拖慢破解速度。所以哪怕你的项目再小密码存储也建议直接上 BCrypt这一行代码的改动带来的安全收益远大于那一点点性能损耗。6. JavaWeb 登录注册常见问题排查四个必踩的坑与解决方案6.1 现象一Tomcat 启动报 404访问 login 路径找不到资源原因排查方向很明确Servlet 类没有正确编译到WEB-INF/classes目录或者WebServlet注解没被扫描到。解决方案是检查 IDEA 的项目结构确认web.xml配置了metadata-completefalse或者干脆删掉这个属性否则注解会被容器忽略同时确认 Servlet 类上有WebServlet(/login)注解且包名和实际路径一致。一个实用的排查技巧是启动 Tomcat 时看日志里有没有Mapping servlet: LoginServlet to [/login]这行输出如果没有说明映射没生效直接去查注解或配置文件省得在页面上反复刷新浪费时间。6.2 现象二表单提交后中文用户名乱码这是 JavaWeb 项目的经典坑。原因是 POST 请求的表单数据默认按 ISO-8859-1 解码而页面是 UTF-8 编码两边对不上就乱码。解决方式是在doPost方法最前面加一行request.setCharacterEncoding(UTF-8);注意这句话必须放在request.getParameter之前才有效放在后面就晚了。还有一个隐蔽的坑如果你的 Tomcat 版本比较老8.0 以下还必须在server.xml里给 Connector 加URIEncodingUTF-8才能处理 GET 请求的乱码POST 请求的乱码只靠 Servlet 里的setCharacterEncoding就能解决。6.3 现象三登录成功后跳转到 success.jsp刷新一下变成 404原因在于用了sendRedirect做重定向浏览器地址栏变成了http://localhost:8080/your-app/success.jsp如果你把success.jsp放在了WEB-INF目录下Tomcat 出于安全考虑禁止外部直接访问该目录下的资源于是刷新就 404。解决方式有两种要么把 JSP 放在webapp根目录下不进WEB-INF要么改用request.getRequestDispatcher(success.jsp).forward(request, response)做服务器内部转发。我在项目里更倾向用 forward 处理成功页面因为WEB-INF下可以防止用户绕过登录逻辑直接访问页面这个结构本身就是一种安全手段但代价是 URL 不变且刷新会重复提交表单。两个方案都有取舍看你的页面需要哪种行为。6.4 现象四Tomcat 10 上运行报ClassNotFoundException: javax.servlet.*这是从 Tomcat 9 升级到 Tomcat 10 最常见的兼容性问题。Tomcat 10 把 Servlet API 的包名从javax.servlet改成了jakarta.servlet老代码里import javax.servlet.http.HttpServlet全部失效。解决方案有两种一是把代码里所有javax.servlet替换为jakarta.servlet同时确保项目构建依赖用的也是jakarta.servlet-api二是把 Tomcat 降回 9.x但这不是长久之计。IDE 的全局替换功能可以一键完成包名迁移但替换后要重点检查web.xml头部的命名空间引用web-app标签的xmlns也要同步改为jakarta命名空间。7. JSP 页面的进阶用法用 JSTL 和 EL 表达式改造跳转后的展示逻辑7.1 在 success.jsp 中通过 session 显示当前登录用户前文的登录逻辑已经用session.setAttribute(username, username)存了登录用户但success.jsp里要把它显示出来就需要 JSP 的内置对象session配合 EL 表达式。核心做法是这样!DOCTYPE html html head titleSuccess/title /head body h1Welcome, ${sessionScope.username}!/h1 pYou have successfully logged in./p a hreflogoutLogout/a /body /html${sessionScope.username}是 EL 表达式的标准写法sessionScope指定查找范围username对应setAttribute时用的 key。这里有个细节值得注意如果用% session.getAttribute(username) %这种 scriptlet 写法也能实现但页面里混入 Java 代码会让 JSP 变得难以维护EL 表达式是更现代的做法。如果你传的是User对象还可以直接点出属性比如${sessionScope.user.username}比取单个字符串值方便得多。7.2 用 JSTL 的 c:if 在 failure.jsp 中按错误类型显示不同提示更进阶一点的用法是结合 JSTL 标签库根据不同的错误类型展示不同的反馈。既然失败页面是通过sendRedirect(failure.jsp?message...)带参数跳转的那在 JSP 里通过${param.message}就能拿到这个值。用c:if做条件展示% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html head titleRegistration Failed/title /head body h1Registration Failed/h1 c:choose c:when test${param.message existed} pUsername already exists, please choose another one./p /c:when c:when test${param.message weakpassword} pPassword does not meet the requirements./p /c:when c:otherwise p${param.message}/p /c:otherwise /c:choose a hrefindex.jspBack to Login/Register/a /body /html这套写法的好处是错误提示和业务逻辑解耦Servlet 里只需要传一个简短的错误码如existed或weakpassword页面负责把错误码翻译成用户能看懂的文字。如果你在多个地方都要跳转到失败页改提示文案不用动 Servlet只改 JSP 即可。使用 JSTL 记得在pom.xml或 lib 目录里引入jstl-1.2.jar很多新手卡在页面直接展示 JSTL 源码这个问题上原因就是忘了加依赖。7.3 从这套示例走向完整项目的最后一块拼图把登录注册做成可以给别人用的系统只靠 JSP 展示成功失败远远不够。后续要补的三件套是用户表设计、Session 拦截器、验证码。用户表至少要有id、username、password_hash、created_at四个字段username加唯一索引Session 拦截器可以用一个LoginFilter实现检查所有受保护页面的session.getAttribute(user)是否为 null验证码推荐用kaptcha库生成图片比手写字母数字组合靠谱得多。我自己的经验是先把这套基础示例跑通再按这个顺序逐步加功能每一步都验证一次比拿到完整项目直接抄要扎实得多。这也是我后来带项目时强制自己走的流程——先跑通最简路径再逐层加固。希望这套代码能帮你把 JavaWeb 的登录注册这块基础补扎实。本文还有配套的精品资源点击获取