# Java_web_demo
**Repository Path**: EasonTong/java_web_demo
## Basic Information
- **Project Name**: Java_web_demo
- **Description**: 用于Java web 后端开发课程教学
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-09-22
- **Last Updated**: 2026-10-27
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# Java Web 登录校验教学 Demo
一个 Spring Boot 写的注册 / 登录小项目,专门用来讲清楚一件事:
> **HTTP 本身记不住你是谁,那服务器是怎么知道"你已经登录了"的?**
答案就两块拼图:**Session**(把状态存在服务器)和 **Filter**(在每个请求进门之前查一次)。
---
## 一、先跑起来
**推荐方式**:用 IntelliJ IDEA 打开项目,直接运行 `TeacherApplication` 的 `main` 方法。
启动后浏览器访问 。
> **演示账号:`QQQ` / `123`** —— 在 `AuthService` 的构造器里预置,启动就能直接登录,省得每次都先注册。
> 想练注册流程,再走 `register.html` 建新账号(注意 `QQQ` 已被占用,不能重复注册)。
> ⚠️ **不要用 `mvn spring-boot:run`**。本项目 `pom.xml` 里 spring-boot 插件配了 `true`,该命令不会真正启动应用。用 IDEA 运行 main 方法,或者手动 `java -cp` 指定 classpath。
**已知限制**(讲课时可以顺带说明):
- 用户数据存在内存 `LinkedList` 里,**重启应用后注册的账号就没了**,这是刻意为之,为了不引入数据库。
- 端口写死在 `application.properties` 的 8080。
---
## 二、接口与页面一览
| 地址 | 类型 | 需要登录 | 说明 |
|---|---|---|---|
| `/` 或 `/index.html` | 页面 | 否 | 入口,选择登录或注册 |
| `/login.html` | 页面 | 否 | 登录表单 |
| `/register.html` | 页面 | 否 | 注册表单 |
| `/dologin` | 接口 | 否 | 校验账号密码,成功后写入 Session |
| `/doRegister` | 接口 | 否 | 注册新用户 |
| `/main.html` | 页面 | **是** | 受保护页面,未登录访问会被打回登录页 |
| `/whoami` | 接口 | **是** | 返回当前登录人,用来验证会话有效 |
| `/logout` | 接口 | **是** | 销毁会话并跳回登录页 |
---
## 三、知识点与原理
### 3.1 起点:HTTP 是无状态的
HTTP 协议本身不记得"上一次请求是谁发的"。你在登录页提交了账号密码,服务器校验通过后回了句"登录成功",**然后它就忘了**。
下一个请求再来时,服务器看到的只是一堆陌生的请求头,它并不知道你和刚才那个人是不是同一个。
所以需要额外的手段,把"身份"在两万次请求之间保存下来。
### 3.2 Cookie —— 把状态存在客户端
服务器可以在响应头里写 `Set-Cookie`,浏览器收到后会把这段内容**存到本地**,并且在**之后访问同一网站的每个请求里自动带上**。
```
服务器 → 浏览器: Set-Cookie: JSESSIONID=8F3A2C7E91B4
浏览器 → 服务器: Cookie: JSESSIONID=8F3A2C7E91B4
```
关键点:
- **自动携带**是浏览器行为,前端代码一行都不用写。
- Cookie 存在用户电脑上,**用户可以随便看、随便改**(F12 → Application → Cookies)。
- 容量小(一般每个域名 4KB 左右)。
### 3.3 Session —— 把状态存在服务器
Session 是服务器这边的一块存储空间。它的工作方式:
1. 用户登录成功,服务器**新建一条会话记录**,随机生成一个不会重复的编号(`JSESSIONID`);
2. 把真正的数据存在这条记录里,比如 `loginUser = tom`;
3. 只把这个**编号**通过 Cookie 发给浏览器;
4. 以后浏览器每次请求都带上这个编号,服务器拿编号去表里一查,就知道你是谁。
一句话概括:**Cookie 存的是钥匙,Session 存的是柜子里的东西。**
浏览器上只能看到一个随机编号,看不到用户名是谁 —— 这是 Session 相对"把用户信息直接写进 Cookie"最本质的优势:**敏感数据不离开服务器**。
### 3.4 Cookie 和 Session 的分工
| | Cookie | Session |
|---|---|---|
| 存在哪 | 用户的浏览器 | 服务器的内存 / 磁盘 |
| 存什么 | 一个编号(JSESSIONID) | 真正的登录信息 |
| 用户能看到吗 | 能,也能改 | 看不到 |
| 谁在传输 | 每个请求自动带上 | 不传输,只传编号 |
| 生命周期 | 可设置过期时间 | 默认浏览器关闭后失效,也可配置超时 |
两者不是二选一的关系,而是**配合使用**:Session 负责存,Cookie 负责把钥匙带回来。
### 3.5 Filter —— 在请求进门之前设一道岗
Filter(过滤器)是 Servlet 规范里的组件。它的位置很特殊:
```
浏览器 → 【Filter】→ Controller / 静态页面
```
对每个请求都会先执行一遍 `doFilter` 方法,代码里可以做三件事:
1. **直接放行** —— 调用 `chain.doFilter(request, response)`,请求继续往后走;
2. **拦下来换个方向** —— 比如 `resp.sendRedirect("/login.html")`,请求根本到不了 Controller;
3. **改写请求或响应** —— 本项目没用到,但这是过滤器的常规用途(比如统一编码、统一日志)。
它最大的价值是**横切**:不用动任何 Controller 代码,就能给所有请求加上统一的登录检查。本项目里 `LoginFilter.java` 是个独立文件,`BasicController` 里没有一行代码提到"校验登录"。
#### 白名单为什么必须存在
过滤器拦的是"所有请求",但有几个地址**不登录也必须能访问**:
- `/login.html` —— 不登录才能打开登录页,否则没法登录;
- `/register.html`、`/dologin`、`/doRegister` —— 同理;
- `/`、`/index.html` —— 入口页。
如果漏掉 `/login.html`,就会**死循环**:访问登录页 → 过滤器发现你没登录 → 重定向到登录页 → 又发现你没登录 → 再重定向……浏览器最后会报"重定向次数过多"。
这个坑在 `demonstrate/filter-flow.html` 里做了两遍:模式①的**场景六**会完整走一遍死循环,模式②里还能手动把 `/login.html` 从白名单去掉自己试。
---
## 四、代码走读
### 4.1 登录:把状态写进 Session
`src/main/java/com/dashuju/teacher/demos/web/BasicController.java`
```java
@RequestMapping(value = "/dologin", produces = "text/html;charset=UTF-8")
@ResponseBody
public String dologin(@RequestParam(name = "username", defaultValue = "unknown user") String username,
@RequestParam(name = "password", defaultValue = "unknown user") String password,
HttpSession session) { // ← 加一个参数,Spring 会自动注入当前会话
MyUser u = new MyUser(username, password);
if (this.authService.query_user(u)) {
// 关键:登录成功后把用户信息存进 session
session.setAttribute("loginUser", username);
return page("登录成功", "当前用户:" + username, "main.html", "进入主页");
} else {
return page("登录失败", "用户名或密码错误", "login.html", "重新登录");
}
}
```
方法多了一个 `HttpSession session` 参数,Spring MVC 会自动把当前请求对应的会话对象传进来,不需要手动去拿。
> **顺手修掉的一个坑**:原来这里直接 `return "文字 进入主页"`,
> 但 `@ResponseBody` 返回 String 时 Spring 默认按 `text/plain` 输出,
> 浏览器会把 `` 标签**原样显示成文字**,根本点不了。
> 现在加了 `produces = "text/html;charset=UTF-8"`,再用一个 `page()` 方法拼出「标题 + 提示 + 按钮」的极简页面,
> 登录成功、登录失败、注册成功、注册失败四种结果都走它。
配套的两个接口:
```java
@RequestMapping("/logout") // 退出:销毁会话
public String logout(HttpSession session) {
session.invalidate();
return "redirect:/login.html";
}
@RequestMapping("/whoami") // 查询:证明会话还在
@ResponseBody
public String whoami(HttpSession session) {
Object loginUser = session.getAttribute("loginUser");
return loginUser == null ? "未登录" : loginUser.toString();
}
```
`/whoami` 看起来多余,其实是**最好的教学道具**:它把"服务器还记得我"这件事变成了页面上看得见的一行字。
### 4.2 拦截:过滤器
`src/main/java/com/dashuju/teacher/filter/LoginFilter.java`
```java
@Component // ← 加上它,Spring Boot 就自动把这个过滤器注册到所有请求上
@Order(1)
public class LoginFilter implements Filter {
// 白名单:这些地址不登录也能访问
private static final List WHITE_LIST = Arrays.asList(
"/", "/index.html", "/login.html", "/register.html",
"/dologin", "/doRegister", "/error", "/favicon.ico");
@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();
// 1. 白名单直接放行
if (WHITE_LIST.contains(uri)) {
chain.doFilter(request, response);
return;
}
// 2. 看 session 里有没有登录时存进去的 loginUser
HttpSession session = req.getSession(false); // false:没有就返回 null,不新建
Object loginUser = (session == null) ? null : session.getAttribute("loginUser");
if (loginUser != null) {
chain.doFilter(request, response); // 已登录,放行
} else {
resp.sendRedirect(req.getContextPath() + "/login.html"); // 未登录,打回登录页
}
}
}
```
三个值得停下来讲一句的地方:
- **`@Component` 就够了**。不用写 `web.xml`,也不用写配置类 —— Spring Boot 检测到 `Filter` 类型的 Bean 会自动注册。这是本项目"最少改动"的取舍。
- **`getSession(false)` 的 `false` 很关键**。传 `true`(默认)会在没有会话时**新建一个**,那样每个未登录用户都会被分配一个空 Session,白白占服务器内存。这里只是想"看看有没有",所以传 `false`。
- **`sendRedirect` 返回 302**,浏览器收到后会自己跳到登录页。学生按 F12 看 Network 面板能直接看到这个 302。
### 4.3 受保护页面
`src/main/resources/static/main.html`
页面本身是静态的,它通过一个 `fetch` 请求去问服务器"我是谁",把结果显示出来:
```javascript
fetch('whoami')
.then(function (resp) { return resp.text(); })
.then(function (name) { document.getElementById('who').innerText = name; });
```
这个页面的意义在于:它能**同时验证两件事** —— 会话确实存在,且未登录时根本打不开这个页面。
---
## 五、完整请求流程(把上面的拼起来)
**登录成功那一次:**
```
浏览器 ──POST /dologin (username=tom&password=123)──▶ LoginFilter
│ 白名单,放行
▼
BasicController
│ 校验通过
│ session.setAttribute("loginUser","tom")
▼
浏览器 ◀──200 Set-Cookie: JSESSIONID=8F3A…────────── Tomcat 自动写入
```
**之后访问受保护页面:**
```
浏览器 ──GET /main.html (Cookie: JSESSIONID=8F3A…)──▶ LoginFilter
│ 不在白名单
│ 查 session → 找到 tom
▼ 放行
浏览器 ◀──────────────200 main.html──────────────────
```
**未登录访问受保护页面:**
```
浏览器 ──GET /main.html (没有 Cookie)──────────────▶ LoginFilter
│ 不在白名单
│ 查 session → null
▼
浏览器 ◀──302 Location: /login.html───────────────── 打回登录页
```
---
## 六、课堂演示脚本
### 演示一:未登录直接被拦(30 秒,最能说明问题)
1. 应用启动后,**不要点登录**,直接在地址栏输入 `http://127.0.0.1:8080/main.html`
2. 页面瞬间跳回登录页
3. 按 F12 → Network,找到那条 `main.html` 请求,看到 **302 Found**,`Location: /login.html`
4. 提问:这个跳转是谁干的?—— 不是页面里的 JS,是服务器的过滤器
### 演示二:登录后拿到钥匙
1. 直接用演示账号 `QQQ` / `123` 登录(想练注册就先去 `register.html` 建一个)
2. 登录成功页点「进入主页」,页面上显示出"当前登录用户:QQQ"
3. 按 F12 → Application → Cookies,看到 `JSESSIONID`,**这就是那把钥匙**
4. 按 F12 → Network,点开 `whoami` 请求,看请求头里自动带的 `Cookie: JSESSIONID=…`
5. 强调:用户名 `QQQ` 在 Cookie 里**根本看不到**,它在服务器那边
### 演示三:删掉钥匙会怎样
1. 在 F12 的 Cookies 面板里把 `JSESSIONID` 删掉
2. 刷新 `main.html` → 又被踢回登录页
3. 说明:服务器上的会话数据其实还在(没到超时),只是**你带不回钥匙了**
### 演示四:两个浏览器窗口
1. 正常窗口登录 `tom`
2. 打开一个**无痕窗口**,访问 `main.html` → 依然被拦
3. 提问:无痕窗口为什么进不去?—— 因为它拿不到那个窗口的 `JSESSIONID`,会话是**按浏览器、按会话隔离**的
### 演示五:退出登录
点主页的"退出登录",跳回登录页后再访问 `main.html`,又被拦。
说明 `/logout` 里的 `session.invalidate()` 把服务器上的记录**删掉了** —— 这次不是丢钥匙,是柜子里的东西没了。
### 配两个可视化页面(`demonstrate/` 目录)
这两个页面是**独立的 HTML**,不需要启动后端,用浏览器直接打开即可,适合投影。
每个页面都有**两种模式**,左上角切换:
| 模式 | 用途 | 特点 |
|---|---|---|
| **① 一步一讲解** | 上课主讲 | 点「下一步」按顺序走,每步都配一段**讲解词**(可直接照着念)和"这边看哪里"的提示;支持键盘 ← → 翻页、自动播放 |
| **② 自由尝试** | 学生自己玩 / 提问时现场验证 | 自己点操作、自己换条件,看两边状态和报文怎么变 |
| 文件 | 一步一讲解(模式①) | 自由尝试(模式②) |
|---|---|---|
| `demonstrate/cookie-session.html` | 13 步走完"提交登录 → 建会话 → 发钥匙 → 带钥匙访问 → 删钥匙被拦 → 打回登录页" | 7 个操作按钮:登录 / 访问 main.html / 访问 whoami / 访问 login.html / 退出登录 / F12 删 Cookie / 重置,带操作记录 |
| `demonstrate/filter-flow.html` | 6 个场景依次讲完(未登录被拦 → 白名单放行 → 登录后放行 → /whoami 取数据 → 退出后又被拦 → 白名单漏配死循环),共 42 步 | 选请求地址 + 切换登录状态,看过滤器两步判定;**勾选"把 /login.html 从白名单去掉"可以当场演示死循环** |
两个模式的界面长相一致——左边是浏览器 / 服务器的存储区(或过滤器流程),中间是网络报文,下面是讲解区。**从模式①切到模式②,学生会发现自己手动点出来的结果和刚才讲的完全一样**,这一步的对照很有说服力。
建议的用法:**先放演示页讲原理,再切到真实项目里跑一遍**,学生的印象会深很多。
---
## 七、三个递进提交(讲"功能是一步步长出来的")
本仓库的历史是刻意设计过的,正好对应这节课的思路演进:
| 提交 | 说明 | 此时能做到 | 此时还做不到 |
|---|---|---|---|
| `6319089` | 初始版本 | 能注册、能登录 | 不记状态、不校验,任何页面都能直接打开 |
| `bb8643e` | 把登录状态保存到 Session | 登录状态记住了,`/whoami` 能读出当前用户 | 还没有访问控制,`main.html` 谁都能进 |
| `dd8e091` | 新增 LoginFilter | 未登录访问受保护页面会被拦 | —— |
课堂上可以用这两条命令直观展示:
```bash
git diff 6319089 bb8643e # 只看"加 Session"这一步改了什么
git diff bb8643e dd8e091 # 只看"加 Filter"这一步改了什么(只有一个新文件)
```
甚至可以直接 `git checkout bb8643e` 切到"还没有过滤器"的那一版,让学生亲眼看到:**同一个 `main.html`,加过滤器前能直接打开,加完之后就被拦了。**
---
## 八、常见问题与排查
| 现象 | 原因 | 怎么办 |
|---|---|---|
| 登录成功但一刷新又是未登录 | 没写 `session.setAttribute`,或过滤器读的 key 名字不一致 | 确认两边都是 `loginUser` |
| 打开任何页面都无限跳登录页 | 白名单漏了 `/login.html` 或其他必须放行的地址 | 对照 `LoginFilter.WHITE_LIST` |
| 过滤器完全没有生效 | 类上漏了 `@Component` | 加注解后重启应用 |
| 未登录时服务器内存越来越高 | `getSession()` 用了默认的 `true`,给每个访客都建了空会话 | 改成 `getSession(false)` |
| 重启应用后账号全没了 | 用户存在内存 `LinkedList`,没有接数据库 | 这是本 Demo 的刻意设计 |
| 想改包名成 `jakarta.servlet` | 本项目是 Spring Boot 2.6.13,用的是 `javax.servlet` | 升到 Spring Boot 3 才需要改,别乱改 |
| `mvn spring-boot:run` 启动没反应 | `pom.xml` 里插件配了 `true` | 用 IDEA 跑 main 方法 |
---
## 九、项目结构
```
demo/
├── README.md 本文件
├── pom.xml Spring Boot 2.6.13 / Java 1.8
├── demonstrate/ 可视化演示页(独立 HTML,双击打开)
│ ├── cookie-session.html Cookie 与 Session 原理
│ └── filter-flow.html 过滤器判定流程 + 白名单死循环实验
└── src/main/
├── java/com/dashuju/teacher/
│ ├── TeacherApplication.java 启动类
│ ├── demos/web/
│ │ └── BasicController.java 登录、注册、退出、whoami
│ ├── filter/
│ │ └── LoginFilter.java 登录校验过滤器 ← 本节课重点
│ ├── pojo/MyUser.java 用户实体
│ └── service/AuthService.java 用户存取(内存 LinkedList)
└── resources/
├── application.properties 端口 8080
└── static/ 纯静态页面,无模板引擎
├── index.html
├── login.html
├── register.html
└── main.html 受保护页面
```
---
## 十、可以留给学生的思考题
1. 如果把用户名直接写进 Cookie(而不是存 Session),会有什么安全问题?
2. Session 数据存在服务器内存里,如果网站部署了 3 台服务器(做负载均衡),会出什么问题?该怎么解决?
3. 登录状态默认到浏览器关闭就失效。如果希望"7 天内免登录",应该改哪里?
4. 现在的过滤器是在**每个**请求里判断白名单。如果项目有 50 个页面,白名单会变成什么样?有没有更好的做法?