news 2026/10/18 2:13:12

从零实现Java Web登录功能:Servlet+JSP+JDBC全流程与踩坑实录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零实现Java Web登录功能:Servlet+JSP+JDBC全流程与踩坑实录

做 Java Web 开发,早晚都要过登录页面这道坎。别小看这个“输入用户名密码,点一下按钮”的流程,真正动手从零写一遍,遇到的坑比想象中多得多。这篇文章就记录我从零开始用 Java 写一个登录页面的完整过程,包括设计思路、前后端代码、数据库交互,以及那些让我调试到深夜的典型问题。如果你正准备独立完成一个带登录功能的小系统,这篇文章应该能帮你少走不少弯路。

1. 写登录页面前,先把需求和方案盘清楚

1.1 这个登录页面到底要解决什么问题

登录页面的核心任务就三个:收集用户凭证、验证身份、维持登录状态。听起来简单,但把这三个环节拆开,每一环都有不少细节。

首先是收集凭证,说白了就是用户名和密码的输入框加一个提交按钮。这里要考虑用户输错了怎么办、要不要做前端校验、密码框要不要支持切换明文显示。为了用户体验,这些交互细节在产品里通常都会有要求。

其次是验证身份。前端把用户名密码交给后端,后端要去数据库里查这个人是否存在、密码是否正确。这里牵涉到密码的存储方式,如果数据库里存的是明文密码,那就是在给自己埋雷。

最后是维持登录状态。用户登录成功后,不能每点一个页面就重新登录一次,这就需要用到会话机制。Java Web 里最经典的就是 HttpSession,我们可以把用户信息塞进 Session 里,后面的请求通过 Session 判断用户是否登录。

我自己这次做的是一个典型的单体 Web 应用,前端用 JSP 页面,后端用 Servlet 处理请求,数据层用 JDBC 操作 MySQL 数据库。选这套组合不是因为它最时髦,而是因为它最能暴露基本功问题。现在很多教程一上来就是 Spring Boot 一把梭,反而把 Servlet 生命周期、请求响应模型这些基础概念给掩盖了。如果你是想练内功,用 Servlet + JSP 走一遍登录流程,收获会更大。

1.2 常见的实现方案选型对比

做登录功能,摆在面前的路有好几条。我在动手前也列了一张对比表,把几种主流方案的优缺点和适用场景理了一遍:

实现方案优点缺点适用场景
JSP + Servlet + JDBC技术栈简单直接,能看清底层原理代码冗余,页面与逻辑耦合,维护成本高学习练手、小型传统项目
Spring MVC + MyBatis分层清晰,开发效率高,社区生态成熟配置繁琐,学习曲线较陡中小型企业项目
Spring Boot + Spring Data JPA起步快,约定优于配置,适合快速迭代抽象层级多,出了问题排查链路长微服务、快速交付场景
纯前后端分离(Vue + Spring Boot)用户体验好,前后端可独立开发需要处理跨域、Token 鉴权等额外问题中大型系统、专项前端团队

考虑到我是为了吃透原理,最终选了第一种。如果你是在公司里赶进度,那我建议直接上 Spring Boot,效率完全不一样。说到底,选型没有绝对的“最好”,只有“最合适”。

2. 开发前的环境准备与配置细节

2.1 JDK、IDE、依赖包的版本选择

我这次用的环境是 JDK 8、IntelliJ IDEA、Maven 管理依赖,外加一个 Tomcat 9 作为运行容器。之所以选 JDK 8,不是因为懒,而是 JDK 8 在 Servlet 容器兼容性上非常稳,网上踩坑资料也多,遇到诡异问题容易搜到答案。

这里有一个很容易忽略的细节:IntelliJ IDEA 里创建的 Web 项目,如果你是用 Maven,记得在 pom.xml 里声明 servlet-api 和 jsp-api 的依赖,不然代码里 import javax.servlet 会直接报红。但如果你是在 IDEA 的 Java Enterprise 项目里新建的,IDEA 已经帮你把 Tomcat 所需的库关联进来了,大部分情况下不需要手动加依赖。

我还是强烈建议用 Maven 来管理项目,哪怕是一个极小型的登录 Demo。因为后续如果要引入 JSON 解析库(比如 Jackson)、数据库连接池(比如 Druid),在 Maven 里加一行依赖就行,比自己手动下载 jar 包扔进 WEB-INF/lib 目录要省心得多。

2.2 数据库准备与用户表设计

登录功能对数据库的要求并不复杂,一张用户表就够了。但“简单”不等于“随便设计”,我见过很多新手把表结构建得特别随意,后面出现问题又得回头改表,非常折腾。

我这里设计了这样一张用户表:

CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', username VARCHAR(64) NOT NULL UNIQUE COMMENT '用户名,唯一索引', password VARCHAR(128) NOT NULL COMMENT '密码,存储的是加密后的密文', nickname VARCHAR(64) DEFAULT NULL COMMENT '昵称,登录成功后展示用', status TINYINT DEFAULT 1 COMMENT '账号状态:1-正常,0-禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

几个设计要点的思考过程:

  • 用户名加 UNIQUE 约束,这既是业务要求(同一系统里用户名不能重复),也是查询优化手段,后续登录时按用户名查询能命中索引。
  • 密码字段用 VARCHAR(128),不是因为我密码只有 128 位长,而是因为我准备用哈希算法存储摘要,摘要长度通常就是 64 个十六进制字符,留出余量。
  • status 字段看起来多余,但在真实系统里非常必要。账号被封禁、被停用的时候,这一位就能挡住登录请求。
  • 时间字段两个都加上,后面排查问题、做数据统计都用得上。

建完表之后记得插入一条测试数据,方便后面联调。我当时就插入了一条 username 为admin的测试记录,密码我预先用 MD5 加密工具生成了一串密文存进去。直接用 SQL 插入摘要值,是为了验证后端校验流程是否走通。

3. 登录页面前端实现:从静态页到动态交互

3.1 表单结构与样式设计的踩坑记录

前端页面我写的是 login.jsp,放在项目的 webapp 目录下面。整体结构就是一个 form 表单,包含用户名输入框、密码输入框、一个登录按钮,外加一个用于显示错误信息的区域。

如果你以为这部分很快就能搞定,那你就错了。我在样式上花的精力一点都不比后端少。比如最简单的“输入框自动聚焦”,按用户体验规范,页面加载后光标应该自动定位在用户名输入框里,我一开始忘记加 autofocus 属性,用户登录时还得手动点一下输入框,这个体验差距在验收时直接被指出来了。

还有密码框的 placeholder 提示文案,如果写“请输入密码”,移动端输入法可能会把大小写键盘弹出来,影响输入效率。后来我改成了“请输入6-16位密码”,既给了输入约束提示,也减少了用户输错的可能。

整个表单我最开始做得比较粗糙,后来参考了一些成熟登录页面的布局方式,加了圆角边框、输入框阴影、按钮 hover 变色这些样式。前端这一块并不是简单堆代码,而是要对用户操作习惯有一定敏感度。

3.2 前端校验逻辑的实现要点

页面上的校验,核心目标是拦住“非空”这个最基本的问题。用户没填用户名就点击登录,这属于一眼就能看出来的低级错误,完全没有必要把请求发到后端去。

我在 login.jsp 里嵌了一段 JavaScript 校验,大概思路是这样的:

function validateForm() { var username = document.getElementById("username").value.trim(); var password = document.getElementById("password").value.trim(); if (username === "" || password === "") { document.getElementById("errorMsg").innerText = "用户名和密码不能为空"; return false; } if (password.length < 6 || password.length > 16) { document.getElementById("errorMsg").innerText = "密码长度必须在6到16位之间"; return false; } return true; }

这段代码里有两个容易踩的小坑:

  • 获取输入框的值时一定要先用 trim() 去掉首尾空格。用户复制粘贴用户名时很容易带一个空格,如果不处理,后端数据库里明明有这个用户,却死活登录不上去,这种问题排查起来特别费劲。
  • 校验失败时要返回 false,并且要把错误提示显示出来。如果返回 true,表单就会直接提交,后端可就白忙活了。

前端校验只是“体验层面”的第一道关卡,真正的安全校验必须放在后端。这是我一直强调的一个原则,前端的 JS 代码都是可以被篡改或者绕过的,绝对不能把前端校验当作安全措施来依赖。

3.3 Servlet 接收请求与处理流程

用户点击登录按钮后,form 表单以 POST 方式提交数据到服务端。这里为什么用 POST 而不是 GET?原因很简单:GET 请求会把参数拼在 URL 后面,用户名是肉眼可见了,密码也会直接暴露在地址栏、浏览器历史记录、服务端访问日志里,这是绝对不可接受的。所以我坚定的选择了 POST。

对应的 Servlet 我取名为 LoginServlet,在 web.xml 或者用注解 @WebServlet("/login") 来做映射。这里我选择用注解方式,省去了 XML 里繁琐的配置。

Servlet 的核心逻辑是这样的:

@WebServlet("/login") public class LoginServlet extends HttpServlet { private static final long serialVersionUID = 1L; @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // GET 请求直接转发到登录页面 request.getRequestDispatcher("/login.jsp").forward(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username").trim(); String password = request.getParameter("password").trim(); // 服务端二次校验参数非空 if (username == null || username.isEmpty() || password == null || password.isEmpty()) { request.setAttribute("error", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } UserDao userDao = new UserDao(); User user = userDao.findByUsername(username); if (user == null) { request.setAttribute("error", "用户不存在"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 校验密码 String encryptedPassword = MD5Util.md5(password); if (!user.getPassword().equals(encryptedPassword)) { request.setAttribute("error", "密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 密码正确,允许登录 HttpSession session = request.getSession(); session.setAttribute("currentUser", user); response.sendRedirect(request.getContextPath() + "/welcome.jsp"); } }

这段代码是登录流程的主干,写起来行云流水,但在实际开发过程中,我在几个环节卡了不少时间,后面我会专门展开讲。

这里有一个非常重要的细节:POST 请求接收中文参数时乱码几率特别大,所以我第一件事就是调用request.setCharacterEncoding("UTF-8"),并且放在读取任何参数之前。这个调用的位置很关键,如果你先读了参数再去设置编码,那基本就晚了,因为 Tomcat 在解析请求体的时候已经按照默认的 ISO-8859-1 把数据解码完了。

4. 数据访问层:JDBC 连接与密码加密的正确姿势

4.1 手写 JDBC 工具类的几个关键取舍

这个项目的持久层我用了最朴素的 JDBC,没有引入 MyBatis 这些框架。自己手写一遍就能体会到一个道理:框架是在帮你省时间,但省下来的前提是你自己知道它帮你省了什么。

我写了一个 DBUtils 类用来管理数据库连接,核心代码里包含这几部分:

  • 通过 Class.forName 加载驱动类,然后使用 DriverManager.getConnection 获取连接。
  • 把数据库连接地址、用户名、密码放在配置文件里,方便切换环境。我当时用了 db.properties 来保存这些信息,再用 Properties 类来读取。
  • 每次操作完数据库,在 finally 块里释放资源,关闭的顺序是先 ResultSet、再 Statement、最后 Connection。

你问我为什么不直接用连接池?其实真实项目里肯定要用 Druid 或 HikariCP,我自己这里接触过一段时间的 HikariCP,知道它的优势。但在学习阶段先用原生 JDBC 把连接、释放资源的逻辑调通,再去理解连接池会更容易。如果你现在是做公司项目,千万不要学我这样直接用 DriverManager,生产环境连接数是扛不住的。

4.2 密码加密:千万不要明文存储

这是我在这类项目里最想强调的一件事:密码在数据库里绝对不能存明文。

很多新手写登录功能,数据库表里的 password 字段直接就是123456,然后登录验证时一句select * from t_user where username = 'admin' and password = '123456'完事。这在真实世界里属于安全事故级别的失误,因为任何人只要看了一眼数据库,所有用户的密码就全泄露了。

我是用 MD5 做密码摘要的。虽然 MD5 在现在的一些安全标准里已经不算强哈希了,但在学习项目里,它的原理简单,代码量少,足够让你理解“单向哈希”这个概念。我写了一个 MD5Util 工具类,把明文密码通过 MD5 算法转换成一串固定长度的十六进制字符存到数据库里。

注册的时候,用户输入的明文密码经过 MD5 转换后入库;登录的时候,把用户输入的密码也做一次 MD5 转换,再和数据库里的摘要做比对。这样一来,数据库里永远不会出现明文密码。

也许你会问,MD5 不是已经被破解了吗?这里我想补充一点:真实项目里,更稳妥的做法是使用 BCrypt 或 PBKDF2 这类“加盐 + 慢哈希”算法。MD5 的主要问题在于它算得太快,容易被暴力碰撞和彩虹表攻击。但它的思路是对的,只是强度不够。如果你在做生产级项目,直接改成 BCrypt 就好,原理是一致的。

4.3 查询用户信息的 DAO 层实现

DAO 层我命名为 UserDao,只放了两个核心方法:findByUsername 用来按用户名查用户,checkPassword 用来做密码比对。其中 findByUsername 的 SQL 是:

SELECT id, username, password, nickname, status FROM t_user WHERE username = ?

这里一定要用预编译的 PreparedStatement,不要用字符串拼接 SQL。我见过有人在查询里直接写"select * from t_user where username = '" + username + "'",这在学习阶段一旦被恶作剧地输入' or '1'='1这种值,整个用户表就能被拖出来。SQL 注入不是传说,它就是这么实实在在的原理。

PreparedStatement 的好处不仅仅是防注入,它在预编译时就把 SQL 的骨架固定下来了,参数占位符 ? 会被视为数据而不是 SQL 的一部分,所以攻击者输入的恶意内容再花哨,也没法改变 SQL 结构。

4.4 处理查询结果集的注意事项

查询结果出来后,我们从 ResultSet 里取值,这里有一个新手必经的坑:对 ResultSet.next() 的理解。

新手容易犯的问题是:先判断 rs.next() 是否还有记录,如果有就取出数据,然后再动手。如果查询到的结果集是空,rs.next() 返回 false,代码就不会进入取数逻辑。看似理所当然,但实际写的时候很容易忘记第一步判断,导致在空结果集上直接调 getString 而报异常。

这里的常规写法是:

public User findByUsername(String username) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; User user = null; try { conn = DBUtils.getConnection(); String sql = "SELECT id, username, password, nickname, status FROM t_user WHERE username = ?"; ps = conn.prepareStatement(sql); ps.setString(1, username); rs = ps.executeQuery(); if (rs.next()) { user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setNickname(rs.getString("nickname")); user.setStatus(rs.getInt("status")); } } catch (Exception e) { e.printStackTrace(); } finally { DBUtils.close(rs, ps, conn); } return user; }

数据库里如果有的字段本身可能为 NULL,用 getString 没问题,但如果你是做数值统计,就要注意用合适的方法,避免返回 NULL 导致后续出现空指针异常。

5. 会话管理:登录成功之后如何记住用户

5.1 从 HttpSession 到 Cookie 的机制理解

登录成功之后要做的第一步事情,就是把用户信息放进 Session 里。Java 的 HttpSession 底层其实是靠 Cookie 里面的 JSESSIONID 来维持的。当服务端调用 request.getSession() 时,服务器会在内存里创建一个会话对象,并把一个唯一的会话 ID 通过响应的 Set-Cookie 头带回浏览器。

浏览器的请求位置里,会带上这个 JSESSIONID,服务端再根据这个 ID 找到之前创建的那个 Session 对象,然后读出你存在里面的用户信息。这个过程全自动完成。

前端会问:这不就是 Cookie 吗?放在 Cookie 里的会话 ID 如果被偷了怎么办?所以我在项目里就想到 Session 一定会过期,设置了 Session 的超时时间,避免用户长时间不操作但一直保持着登录状态。我设置了 30 分钟,其实这个值可以按业务需求调整。

5.2 登录成功后跳转策略的选择

登录成功后,我是用response.sendRedirect()来跳转到 welcome.jsp 页面,而不是用request.getRequestDispatcher().forward()。这里两者的区别特别重要,也有不少人在这里翻车。

sendRedirect 是一次重定向,服务器告诉浏览器“你去访问这个新地址吧”,浏览器再发出第二次请求。地址栏会变成新的 URL,刷新页面也不会重新提交表单。而 forward 是服务端内部转发,地址栏不变,刷新的时候浏览器可能会重复提交上一次的 POST 请求,造成“重复登录”之类的奇怪问题。

所以我坚持用重定向这种方案。使用重定向还有一个好处,因为它是全新的请求,之后浏览器访问欢迎页面时,Servlet 那边再去读 Session 判断登录状态,逻辑上更加清晰。

5.3 登录状态的统一校验

有了 Session 还不够,还得做统一登录校验。也就是说,用户可以直接在地址栏敲 welcome.jsp 的地址,绕过登录页面进入系统,这当然不行。

我建议在项目里加一个过滤器 Filter,通过它拦截所有 .jsp 请求,先检查 Session 里的 currentUser 是否存在。如果存在,就放行;如果不存在,就重定向到 login.jsp。

过滤器的核心逻辑其实是比较直观的:

@WebFilter("/*") public class LoginFilter 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(); // 如果是登录页面或登录请求,以及静态资源,直接放行 if (uri.endsWith("login.jsp") || uri.endsWith("/login") || uri.contains("static/")) { chain.doFilter(request, response); return; } // 判断 Session 里是否存在用户 HttpSession session = req.getSession(false); if (session != null && session.getAttribute("currentUser") != null) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } } }

我在过滤器这里踩过一个时间不短的坑,就是未登录访问欢迎页时,出现了“无限重定向”的问题。原因是我 Filter 拦下了 login.jsp 请求,但因为判断条件没写好,导致 login.jsp 也被拦截后重定向到自身,浏览器一直循环跳转。解决办法就是简化放行逻辑,把登录页面本身排除在外。

5.4 退出登录的正确处理方式

退出登录看似简单,其实也有讲究。按钮点击后,要做的不是清掉某一个 Session 属性就完事,最稳妥的方式是:

  • 从 Session 中移除用户属性
  • 调用 session.invalidate() 让整个会话失效
  • 重定向回登录页面

这里我要特别提醒:为什么一定要调用 invalidate()?如果你只是 removeAttribute,那会话 ID 本身可能还保持有效,继续用旧的 JSESSIONID 可能还会关联到旧的会话内容。把整个会话作废,不仅可以清掉用户数据,也是减少会话固定类风险的一个手段。

退出按钮我用的是一个简单的 GET 请求,访问 LogoutServlet,然后做以上三步处理。当然,严格来讲退出这种操作也可以要求 POST 请求,用 GET 会不会有安全隐患?比如发起一个图片链接,就能让人退出?实际上这里影响有限,但你要是做严谨的系统,还是建议用 POST 请求来处理退出这种操作。

6. 书写过程中真正卡住我的难点与排查实录

6.1 中文乱码问题:一次请求就够让人绝望

我第一次用 POST 提交表单时,发现提示信息无论如何都是乱码,页面上英文完全正常,中文全是问号,排查过程让当时的我有点着急。

后来定位到问题:我是在 servlet 代码里设置的编码,但 Tomcat 解析请求体时如果发现没有显式声明编码,会默认按 ISO-8859-1 来解码。我虽然写了 request.setCharacterEncoding("UTF-8"),但写的位置太晚了,参数已经被读走,再设置就无效了。

后来我把这行代码提到了代码最开头,然后规定项目里所有页面无论是 JSP 还是 Servlet 的响应,都必须统一使用 UTF-8 编码。为了避免下次踩坑,我总结了一个硬性规范:

  • JSP 头部声明<%@ page contentType="text/html;charset=UTF-8" language="java" %>
  • Servlet 里 request.setCharacterEncoding("UTF-8") 必须放在最前
  • response 设置resp.setContentType("text/html;charset=UTF-8")
  • 数据库连接串里加上?useUnicode=true&characterEncoding=utf8
  • 表结构统一使用 utf8mb4

只要编码链路从头到尾统一,乱码问题基本就不会再出现。

6.2 数据库驱动未加载的经典报错

在项目启动时,我遇到过一个非常经典的报错:ClassNotFoundException: com.mysql.jdbc.Driver。这个错误看起来很简单,但当时的排查过程并不顺利。

原因是我虽然把 MySQL 驱动包的依赖加到了 Maven 的 pom.xml 里,但 Tomcat 运行时并没有从依赖里加载到这个类。后来我发现,如果用的不是 Maven 构建的 war 包进行部署,而是 IDEA 里直接用 Tomcat 启动,有时依赖没有被打进 WEB-INF/lib 目录。

解决办法很暴力也很有效:把驱动 jar 包直接放到 WEB-INF/lib 目录下,或者确保 pom.xml 里的依赖最终被打进 war 包的 lib 文件夹里。这个排查过程让我意识到,编译通过不代表运行时能加载到类,两者的 classpath 是完全不同的概念。

6.3 空指针异常:ResultSet 上的低级错误

我在测试用户不存在的情况时,控制台直接抛出了 NullPointerException。当时第一反应是 SQL 语句写错了,最后才发现,是自己没写 rs.next() 的判断,直接去拿结果集数据。

这个问题的教训很直接:从 ResultSet 里取数据之前,必须先确认有没有数据可拿。而且更要注意的是,findByUsername 方法返回的 User 对象可能为 null,在 Servlet 里拿到这个对象后,调用任何 getter 之前都要判断一下非空。

我在 Servlet 里加了这样一层防御性判断:

if (user == null) { // 用户不存在,回到登录页并提示 }

这么一改,空指针异常就消失了。代码运行过程中,空指针几乎是 Java 开发者最常见的敌人,防御性编程应该成为习惯。

6.4 密码比对不一致的乌龙事件

有一段时间,我很确定数据库里存的密码没有问题,但登录就是失败,对比的字符串怎么都对不上。折腾许久之后我才意识到,我往数据库里手工插入数据时,直接写的目标密文是 MD5 加密过,但在代码中把我的测试密码明文做了加密后,发现两者根本不一致,原因是我在手工造数据时给密码末尾多加了一个空格。

这种问题特别隐蔽,因为肉眼完全看不出来,数据库里的空格和 Java 字符串里的空格在视觉上没有任何区别。解决的方式就是严格统一数据的生成路径,不要有人工手工造数据的随意性。再一个,查询时也先做 trim 处理,把脏数据挡住一层。

6.5 并发登录与会话固定问题初探

项目里我还考虑过一个更进阶一点的问题:一个用户能不能同时在两个设备上登录?如果允许,要不要限制?如果限制,是顶掉前面一个,还是直接拒绝后者?

这个问题在真实系统里通常是有产品规则做约束的,不是码农凭空决定的。但在代码层面,你可以用一张登录会话表和 userId 关联来手动管理会话状态,当新登录产生时,把旧会话标记为失效。

我自己的项目里没有做这么复杂,只是控制了单点登录策略。但我知道,从这台机器的角度看,Session 默认机制其实允许同一用户在多地登录,因为服务端为每个浏览器都创建了全新的会话。想要限制,就要在表里做文章,这是另一个设计级别的话题了。

7. 登录功能的安全加固与经验沉淀

7.1 基础安全清单五项

登录功能写完,不等于登录功能安全。我自己整理了一份基础安全清单,对做这类功能的同学很实用:

  • 密码不能明文存储,至少使用 MD5 + 盐或者更高强度的 BCrypt。
  • 数据库操作必须用 PreparedStatement,防止 SQL 注入。
  • 所有需要保护的后台资源下,统一做登录校验,不能信任 URL 路径自带安全属性。
  • Session ID 不能出现在 URL 上,防止通过浏览器历史泄露;确认关闭 URL 重写。
  • 登录接口要增加失败次数限制,防止暴力破解。

最后一条我在项目里做了尝试。通过给 Session 里加一个 loginFailCount 的属性记录,失败次数达到三次时,在一定时间内拒绝登录请求,并且在界面上提示“尝试次数过多,请稍后再试”。这虽然不能完全防住攻击,但至少提高了暴力破解的成本。

7.2 验证码要不要加

在做登录页面时,我一度纠结要不要加验证码。不加吧,觉得不安全;加吧,又觉得实现很烦。

我最后的结论是:如果这个系统注册了对外的互联网访问,最好加上验证码,并且建议使用图形验证码或者更复杂的滑块验证。如果只是内部系统,用户量也不大,可以先用账户密码加失败次数限制兜底,后续再做验证码升级。

验证码的 Java 实现思路也并不复杂,用 ImageIO 生成一张带随机字符的图片,把正确的验证码存到 Session 里,提交表单时比对用户输入的验证码和 Session 里存的验证码是否一致。注意比对完要立刻清除 Session 里的验证码,防止重复使用。

这里我要强调一个很容易被忽略的点:验证码最大的价值不是防人,而是防机器。加了验证码以后,自动化脚本发起高频登录请求的难度明显提升,这个效果是失败次数限制替代不了的。

7.3 日志记录与监控思路

一个合格的登录模块,还应该有日志记录。谁在什么时候登录成功了,谁在什么时候登录失败了,失败原因是什么,这些都应该有迹可循。

我用 Log4j 2 在 LoginServlet 里打了几个关键节点的日志,比如用户登录成功、用户密码错误、用户账号被禁用等等。日志级别我用了 Info 记录正常流程,用 Warn 记录异常情况。

这里有一个实际操作的细节值得提及:不要在日志里打印密码。无论明文还是密文,密码信息都不应该出现在日志里。日志一旦泄露,密码信息等同泄露。我使用的是匿名化记录方案,记录的是用户名和 IP 地址和时间,密码内容一律不进入日志。

7.4 数据库连接池引入的实际收益

前面说我用 JDBC 直连是为了学习原理,但真要把这个登录功能放到生产环境,连接池是不可跳过的环节。在我写完原生 JDBC 的版本后,又顺手把连接池升级成了 HikariCP,应用启动时初始化最小连接数、最大连接数等参数,运行效率和稳定性都明显提升。

连接池的核心价值在于复用,省去每次创建和销毁连接的开销。一个简单的请求如果每次都要新建数据库连接,在并发量稍大一点的情况下,数据库会被频繁建立连接拖垮。连接池还会帮你做连接有效性检查,避免拿到已经断开的连接。

引入方式也很简单:

HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/yourdb"); config.setUsername("root"); config.setPassword("root"); config.setMaximumPoolSize(10); HikariDataSource dataSource = new HikariDataSource(config);

但要提醒的是,连接池参数不是随便填的,连接数设太小会排队,设太大又会吃掉数据库内存。一个常见经验值是最大连接数不要超过数据库 CPU 核心数的两倍,具体还要看业务复杂度。

8. 最终调试心得与几点建议

写完登录页面,经历各种小问题又逐个解决之后,我自己最深的一点体会是:做功能容易,做可靠的功能难。登录页面这种基础功能,正是锻炼代码可靠性的绝佳场景。

如果你也在写类似的登录功能,我建议你做完基础流程后,一定自己过一遍这些场景:

  • 输入正确的用户名密码,能否正常登录。
  • 输入错误的密码,是否给出明确提示。
  • 用户名不存在时,提示是否合理,有没有泄露“用户是否存在”的敏感信息。
  • 输入空值、超长字符串、特殊字符,程序是否还能稳如磐石。
  • 未登录直接访问内部页面,是否会被正确拦截。
  • 登录成功后,直接访问登录页,是否异常跳转。
  • 退出登录后,再访问内部页面,是否被正确拦截。

这些场景我都会在自己的验证清单里走一遍,只有当每个极端情况都不出问题,我才会觉得这个功能算是真正写完了。

最后再分享一个小技巧:排查登录问题时,不要只盯着 Java 代码,把数据库里的数据、浏览器的网络请求、Tomcat 的控制台日志三个视角结合起来看,绝大多数问题都能快速定位。尤其是浏览器开发者工具里的网络面板,你会发现很多自以为是的“前端问题”,实际上是请求参数没传对,或者是响应状态码背后的服务端问题。

希望这篇登录页面的完整记录,能帮你绕过我踩过的那些坑。写代码的路就是这样,踩坑、爬出来、记住,然后继续往前走。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 2:12:36

香港公司注册怎么办理?

一、香港公司注册是什么&#xff1f;有什么用&#xff1f; 香港公司注册&#xff0c;是指依据香港《公司条例》&#xff0c;向香港公司注册处提交申请&#xff0c;取得公司注册证书及商业登记证的法律程序。 对跨境卖家、外贸企业和品牌方而言&#xff0c;香港公司主要用于&…

作者头像 李华
网站建设 2026/10/18 2:12:28

ESP32-C3物联网量产实战:从开发板到十万台设备的工程化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 2:12:19

C# Open XML生成PPTX:免Office、免COM、高性能动态报表

简介&#xff1a;本资源是一套基于C#与Open XML SDK实现PowerPoint自动化生成的完整开发示例&#xff0c;面向.NET开发者、Office集成应用工程师及需要批量生成教学/汇报PPT的技术人员。项目聚焦于脱离PowerPoint客户端的纯代码PPT构建&#xff0c;覆盖SlideMaster定义、SlideL…

作者头像 李华
网站建设 2026/10/18 2:12:14

从工作流到智能体:Anthropic Agent架构与MCP工具链实战

最近一段时间&#xff0c;手边隔三差五就有刚入行做 AI 应用的朋友甩来一句话&#xff1a;“网上聊 Agent 的都说得天花乱坠&#xff0c;但我连 Agent 到底是什么都没想明白。”这个感觉我太熟悉了。Agent 这个词&#xff0c;一边被营销文案染得五颜六色&#xff0c;一边又在工…

作者头像 李华
网站建设 2026/10/18 2:10:08

滚动渐变导航栏:原生CSS+JS实现高性能视觉反馈

简介&#xff1a;这是一份面向前端初学者与网页设计爱好者的交互式导航栏实现案例&#xff0c;聚焦HTML5、CSS3与JavaScript协同完成滚动时导航栏背景色渐变的动态效果&#xff0c;适用于个人博客、作品集或企业官网等需要提升视觉体验的场景。资源包共5个文件&#xff0c;包含…

作者头像 李华
网站建设 2026/10/18 2:09:29

大尺寸LED数码管驱动:MAX7219结合锁存器与ULN2803的扩展方案

简介&#xff1a;这是一份围绕大尺寸LED数码显示驱动电路设计的PDF技术资料&#xff0c;面向电子工程、嵌入式开发及单片机应用方向的工程师与学习者。内容以MAX7219芯片为核心&#xff0c;结合ULN2803达林顿驱动器与74LS273锁存器&#xff0c;重点介绍动态扫描如何转换为静态显…

作者头像 李华