去年我做车辆轨迹数据管理平台时,最先动手的不是轨迹算法,也不是路线覆盖统计,而是一个看上去特别普通的JavaWeb登录模块。原因很实际:数据管理平台里所有接口、所有页面,都得先确认操作者是谁,登录模块就是这套系统的门禁。JavaWeb技术栈下,登录模块听着简单,无非是一个表单、一次查询、一个Session,可真要把验证码、密码加密、会话保持、SQL注入防护都串起来,涉及的链路比想象中长得多。这篇文章按我实际的开发顺序,把数据管理平台(一)的登录模块完整跑一遍,代码都带详细注释。适合刚接触JavaWeb的开发者,也适合想看看一个完整登录链路到底拆成多少环节的同行。如果你正准备做一个带数据管理后台的系统,哪怕不是车辆轨迹方向,这篇的登录模块思路也完全可以复用。
1. 登录模块在数据管理平台里的定位与需求边界
1.1 数据管理平台为什么第一个功能选登录
一个数据管理平台,尤其是在车辆轨迹场景下,用户构成比普通网站复杂。运营人员要查某辆车在某条路上跑了几个来回、覆盖了多少路程,管理员要维护车辆、司机、路线等基础数据,财务可能要核对费用账单。没有登录模块之前,这些人都可以直接打开系统后台,谁都能改数据,那整个平台就谈不上数据安全。登录模块先搞定身份认证,才能继续谈权限、审计和业务数据归属。
我在项目里把登录模块放在最前面,还有一个很现实的原因:它是一个能把项目骨架彻底拉通的功能。建数据库、写实体、连数据库、写Service、写Servlet、写页面、处理Cookie和Session、加过滤器,每一步都会碰到。这一趟走完,项目的目录结构、依赖管理、异常处理、前后端交互方式基本都定了。后面无论做轨迹查询还是支付对接,都是在这个骨架上填肉,不会再来回改架构。
1.2 认证、授权、审计:登录模块只做第一层
不少初学者会希望登录Servlet把所有事都干了:登录成功后判断角色、渲染菜单、写操作日志,一口气全塞进去。我建议一开始就把边界拆清楚。登录模块的职责聚焦在认证,也就是验证“你是谁”。授权解决“你能干什么”,适合放在独立的AuthFilter里,或者后续接入权限框架。审计负责“你干了什么”,属于日志模块,不能阻塞登录主流程。
我在这个项目里登录成功后只做两件事:把用户基本信息写入Session,更新一下最后登录时间和IP。操作系统、菜单权限这类事情,用JSTL在页面上按角色判断。操作日志则是通过一个异步LogDao写库,不参与登录事务。这样每个类只做一件事,出了问题直接看对应环节就行。
- 认证:登录校验用户名、密码、验证码
- 会话:登录成功后Session保存用户对象
- 退出:销毁Session并清理Cookie
- 失败处理:登录失败回显明确错误信息
- 记住我:可选功能,通过随机令牌实现自动登录
- 安全防护:BCrypt加密、验证码、PreparedStatement防注入
1.3 与后续车辆轨迹模块的衔接
登录模块不是孤立的。数据管理平台后面要做的车辆轨迹数据管理、路线覆盖统计、支付对接,都要基于“当前登录用户”来操作。所以我在Session里放了完整的User对象,而不只是用户名。后续车辆轨迹表如果要记录operator_id,直接从这个对象里取userId即可;订单表创建时同理。登录模块把用户身份这个基础打好,后面所有模块都能少走弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与开发环境搭建:为什么选这套组合
2.1 经典JavaWeb栈而不是一上来就Spring Boot
先说结论:不是Spring Boot不行,而是经典Servlet链路对理解Web原理更友好。数据管理平台一旦数据量上来,并发和业务复杂度都会增加,但登录模块本身并不需要全家桶框架兜底。我这次选的技术栈是:
| 组件 | 版本 | 作用 |
|---|---|---|
| Java | 8/11 | 基础运行环境 |
| Maven | 3.6+ | 依赖管理、构建 |
| Servlet | 4.0 | 处理HTTP请求 |
| Tomcat | 9.x | Web容器 |
| MySQL | 8.0 | 数据存储 |
| JSP + JSTL + Bootstrap | - | 服务端渲染页面 |
| commons-dbutils | 1.7 | 简化JDBC操作 |
| jBCrypt | 0.4 | 密码哈希 |
| HikariCP | 4.0.3 | 数据库连接池 |
这套组合的优点是每一层都能直接看到。请求进入Servlet,Servlet调Service,Service调Dao,Dao操作数据库,最后渲染JSP,整个链路透明。如果直接用Spring Boot,很多细节被框架封装,出现Session失效、URL编码问题时会一头雾水。当然,如果团队技术栈已经固定在Spring Boot,登录逻辑本身没有区别,只是装配方式不同。
2.2 VSCode创建JavaWeb项目的具体步骤
这个项目我全程用VSCode开发。轻量、启动快,配合插件足够支撑一个数据管理平台的JavaWeb开发。如果你是IDEA重度用户,直接看思路就行,工程结构是一样的。
- 安装JDK8/11、Maven、Tomcat9,配好JAVA_HOME和MAVEN_HOME。
- 在VSCode扩展市场安装Extension Pack for Java、Tomcat for Java、Maven for Java。
- 用Maven命令创建Web项目骨架:
bash复制mvn archetype:generate \
-DgroupId=com.datamanager \
-DartifactId=vehicle-data-platform \
-DarchetypeArtifactId=maven-archetype-webapp \
-DinteractiveMode=false
- 在VSCode的settings.json里配置Maven路径,然后通过Tomcat插件添加本地Tomcat,右键项目名选择Run on Tomcat即可启动。
项目建好后,目录结构是这样的:
code复制vehicle-data-platform
├── pom.xml
├── src/main/java/com/datamanager
│ ├── controller # Servlet控制器
│ ├── service # 业务逻辑层
│ ├── dao # 数据访问层
│ ├── entity # 实体类
│ ├── util # 验证码、加密等工具类
│ └── filter # 登录过滤器
├── src/main/resources # 配置文件
└── src/main/webapp
├── WEB-INF/web.xml
├── login.jsp
└── static
2.3 pom.xml 依赖清单与几个坑
以下是本模块的核心依赖,注意scope不能弄错:
xml复制<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>4.0.3</version>
</dependency>
<dependency>
<groupId>commons-dbutils</groupId>
<artifactId>commons-dbutils</artifactId>
<version>1.7</version>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
</dependencies>
javax.servlet-api必须用provided,因为Tomcat容器自带Servlet实现,war包里再打一份会冲突。mysql-connector-java版本要和MySQL 8匹配,否则启动时会遇到Public Key Retrieval is not allowed这类连接报错。HikariCP是我常用的连接池,配好之后比裸JDBC稳很多。
3. 数据库设计与账号模型
3.1 用户表结构与建表SQL
账号表我命名为sys_user,这是平台内部用户的统一账号表。先看建表SQL:
sql复制CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
username VARCHAR(50) NOT NULL COMMENT '登录账号',
password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt哈希后的密码',
real_name VARCHAR(50) DEFAULT NULL COMMENT '真实姓名',
role TINYINT NOT NULL DEFAULT 2 COMMENT '角色:1管理员 2普通用户',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用',
last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间',
last_login_ip VARCHAR(64) DEFAULT NULL COMMENT '最后登录IP',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删 1已删',
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据管理平台用户表';
username加唯一索引,避免同一个账号被重复插入。role字段用数字而不是字符串,代码里定义常量即可,以后扩展角色不需要改表结构。create_time和update_time用数据库默认值,应用层不用每次手动维护,省很多事。
3.2 密码字段为什么叫password_hash
很多老项目里密码字段叫password,存的是明文或者MD5结果,后来想升级加密算法,改字段名又是一轮大工程。我一上来就叫password_hash,从命名上提醒自己和后端同事:这里永远不存明文。字段长度设为100,能容纳BCrypt生成的60位哈希,也给未来算法升级留了余量。
deleted逻辑删除字段也是数据管理平台必须有的。车辆轨迹、费用这类核心数据不可能物理删干净,账号表保持一致,以后做数据恢复和审计都方便。查询时统一带deleted=0条件,逻辑删除的账号无法登录。
3.3 初始管理员账号和与业务表的关系
初始化管理员时,先用一个小工具生成BCrypt哈希:
java复制public class PasswordGenerator {
public static void main(String[] args) {
String password = "Admin@123456";
// gensalt(10) 代表迭代2的10次方次,安全性和性能平衡点
String hash = BCrypt.hashpw(password, BCrypt.gensalt(10));
System.out.println(hash);
}
}
把输出值插入sys_user表。密码策略建议至少8位,包含大小写字母和数字,登录模块里先做基础校验。后续车辆轨迹表里的operator_id就是sys_user.id的外键关系,登录用户身份在整条数据链路里都有意义。
4. 登录核心代码:从请求到响应的完整链路
4.1 完整流程拆解
登录链路看起来是“收到请求、返回响应”,拆开之后是这样:
- 浏览器GET /login,LoginServlet的doGet方法返回login.jsp页面。
- 用户填写表单,POST /login,携带username、password、captcha三个参数。
- LoginServlet的doPost方法先校验验证码,验证码不对直接返回错误。
- 验证码通过后,调用LoginService.login(username, password, ip)。
- LoginService调UserDao.findByUsername查询用户。
- 用户存在且status=1时,用BCrypt.checkpw校验密码。
- 密码正确后,更新用户的last_login_time和last_login_ip。
- LoginServlet把User对象放进Session,重定向到/index。
- 后续所有请求经过AuthFilter,检查Session里有没有用户,没有就拦截回/login。
每一步都不难,难的是拆清楚,同时让每一步的错误信息都能反馈到页面上。验证码错误和密码错误必须分开提示,否则用户会以为密码不对,实际是验证码输错了。
4.2 实体类与UserDao实现
实体类User.java保持简单,字段和数据库一一对应:
java复制public class User implements Serializable {
private Long id;
private String username;
private String passwordHash;
private String realName;
private Integer role;
private Integer status;
private Date lastLoginTime;
private String lastLoginIp;
// getter/setter 省略,项目里用Lombok或者手动生成均可
}
UserDao使用commons-dbutils的QueryRunner,核心方法如下:
java复制public User findByUsername(String username) throws SQLException {
String sql = "SELECT id, username, password_hash AS passwordHash, " +
"real_name AS realName, role, status, " +
"last_login_time AS lastLoginTime, last_login_ip AS lastLoginIp " +
"FROM sys_user WHERE username = ? AND deleted = 0";
return runner.query(sql, new BeanHandler<>(User.class), username);
}
两个细节容易被坑。第一,查询条件带deleted=0,逻辑删除的账号不能登录。第二,SQL里用别名把password_hash映射成passwordHash,这样BeanHandler才能正确封装到实体类。如果依赖默认自动映射,很多版本不支持下划线转驼峰,运行时会得到一堆null参数。
4.3 Service层:业务规则与登录异常
Service是登录校验的核心。自定义一个LoginException,让错误信息能直接抛到Servlet层:
java复制public class LoginException extends RuntimeException {
public LoginException(String message) {
super(message);
}
}
业务逻辑如下:
java复制public User login(String username, String password, String ip) {
if (username == null || username.trim().isEmpty()) {
throw new LoginException("用户名不能为空");
}
User user = userDao.findByUsername(username);
if (user == null) {
throw new LoginException("用户名或密码错误");
}
if (user.getStatus() != 1) {
throw new LoginException("账号已被禁用,请联系管理员");
}
if (!BCrypt.checkpw(password, user.getPasswordHash())) {
throw new LoginException("用户名或密码错误");
}
userDao.updateLastLogin(user.getId(), new Date(), ip);
return user;
}
这里有个刻意的设计:用户不存在和密码错误返回相同提示“用户名或密码错误”,避免暴露系统里存在哪些账号。admin这种账号如果被探测出来,攻击者就可以集中猜密码了。账号禁用单独提示,是为了让系统内用户知道该联系管理员,属于体验和安全之间的平衡。
4.4 LoginServlet、Session与AuthFilter
LoginServlet是前端看到的唯一接口:
java复制@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// 必须在读取参数之前设置编码
req.setCharacterEncoding("UTF-8");
String username = req.getParameter("username");
String password = req.getParameter("password");
String captcha = req.getParameter("captcha");
// 从Session取出验证码,用完立即删掉
String sessionCaptcha = (String) req.getSession().getAttribute("captcha");
req.getSession().removeAttribute("captcha");
if (sessionCaptcha == null || !sessionCaptcha.equalsIgnoreCase(captcha)) {
req.setAttribute("error", "验证码不正确");
req.getRequestDispatcher("/login.jsp").forward(req, resp);
return;
}
try {
User user = loginService.login(username, password, getClientIp(req));
HttpSession session = req.getSession();
session.setAttribute("SESSION_USER", user);
// 登录成功用重定向,避免刷新页面重复提交
resp.sendRedirect(req.getContextPath() + "/index");
} catch (LoginException e) {
req.setAttribute("error", e.getMessage());
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
}
}
几个关键点再强调一遍:req.setCharacterEncoding("UTF-8")必须在读取参数之前调用,否则中文用户名直接乱码。验证码用equalsIgnoreCase,用户不用纠结大小写。登录成功后必须sendRedirect而不是forward,这是Post/Redirect/Get模式,防止刷新页面时反复提交登录表单。
AuthFilter负责拦截未登录请求:
java复制@WebFilter("/*")
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();
// 放行登录、验证码、静态资源
if (uri.endsWith("/login") || uri.endsWith("/captcha")
|| uri.contains("/static/")) {
chain.doFilter(req, resp);
return;
}
// 未登录用户一律跳回登录页
if (req.getSession().getAttribute("SESSION_USER") == null) {
resp.sendRedirect(req.getContextPath() + "/login");
return;
}
chain.doFilter(req, resp);
}
}
静态资源忘了放行是新手最容易踩的坑。登录页的CSS和JS全部无法加载,页面光秃秃的,第一反应往往是代码写错了,实际是过滤器拦了静态文件。index对应一个简单的IndexServlet转发到index.jsp即可,这个环节就是为了验证登录成功后的跳转是否通畅。
5. 登录安全细节:验证码、加密、防注入
5.1 密码为什么不能用MD5
MD5不能存密码,是因为相同密码会产生相同哈希,攻击者用彩虹表或者字典就能反向匹配出常见密码。BCrypt自带随机盐,每次生成的哈希都不一样,攻击者无法预建表,这才是登录模块该用的加密方式。
使用方式非常简单:
java复制// 注册或初始化密码时生成哈希
String hash = BCrypt.hashpw(plainPassword, BCrypt.gensalt(10));
// 登录校验时对比
boolean ok = BCrypt.checkpw(plainPassword, storedHash);
这里的cost因子值得解释一下。BCrypt.gensalt(10)表示迭代2的10次方次,大约需要几十到上百毫秒。这个耗时对用户几乎没有感知,但能把暴力破解的尝试速度从每秒百万次降到每秒几次。cost不是越高越好,太高的cost会让服务器CPU吃紧,生产环境一般10到12之间。
5.2 图形验证码:简单实现与一次性原则
验证码的作用是阻止自动化脚本刷登录。手写一个验证码Servlet并不复杂,关键是两个点:一是生成随机字符并画到图片上,二是把正确答案放到Session且用完立即删除。
java复制@WebServlet("/captcha")
public class CaptchaServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
int width = 100;
int height = 40;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
// 生成4位随机字母数字组合
String code = generateCode(4);
req.getSession().setAttribute("captcha", code);
// 画背景、干扰线和字符,省略具体绘图代码
// ...
resp.setContentType("image/jpeg");
ImageIO.write(image, "jpeg", resp.getOutputStream());
}
}
验证码的答案放在Session里,LoginServlet在取出后立即removeAttribute,保证一个验证码只能使用一次。如果只校验不清除,攻击者可以让脚本反复用同一个验证码,验证码机制就形同虚设。
5.3 SQL注入与XSS防御
登录模块中SQL注入的主要入口是用户名,因为密码不参与SQL拼接。UserDao里用PreparedStatement的?占位符,JDBC驱动会自动转义特殊字符,这个做法必须成为习惯。JSP页面里输出用户名、错误信息时用<c:out>标签,避免用户输入的内容被当作HTML或JavaScript渲染。
还有一个容易被忽略的点:登录失败的错误信息不要原样回显用户输入。比如用户输入了“用户名错误:
