# 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 个页面,白名单会变成什么样?有没有更好的做法?