day15-2 登录认证

实战总结:登录认证(登录功能 + 登录校验)

本文是对《12-后端Web实战(登录认证)》的总结,记录”登录功能徒有其表 → 登录校验闭环”的完整链路。
项目地址:project/tlias-web-management(SpringBoot + MyBatis + JWT + Filter/Interceptor)
对应讲义:讲义/12-后端Web实战(登录认证).md


一、本节要解决的核心问题

上一节做完基础登录后,会出现一个安全漏洞:浏览器直接访问 http://localhost:90不登录也能进后台

原因:HTTP 是无状态协议,每次请求相互独立,服务器根本不知道”这次请求的员工登没登录过”。而部门管理、员工管理等接口在服务端没有任何登录判断

1
2
3
登录功能(徒有其表)  ──►  需要登录校验(闭环)
只查账号密码 服务端统一拦截每一个请求
不存登录标记 先校验是否登录,再放行业务

登录校验的实现思路拆成两块:

关键点 作用 用到的技术
① 存登录标记 登录成功后留一个”已登录”凭证,后续请求能带上 会话技术(令牌 JWT)
② 统一拦截 拦下所有请求,统一校验标记,避免每个接口都写一遍 Filter 过滤器 / Interceptor 拦截器

为什么不能在每个 Controller 方法里手写校验?——相同逻辑会重复出现在所有增删改查接口里,代码繁琐。统一拦截是标准做法。


二、会话技术:三种方案对比

1. 概念澄清

  • 会话:浏览器与服务器之间的一次连接(从打开浏览器第一次访问,到任一方断开)。一次会话可包含多次请求响应。
  • 会话跟踪:服务器识别”多次请求是否来自同一浏览器”,从而在同一次会话的多次请求间共享数据
  • 为什么要共享数据?因为 HTTP 无状态,后一次请求拿不到前一次请求生成的数据。

2. 三种方案横向对比

方案 存储位置 底层依赖 优点 缺点
Cookie 客户端浏览器 HTTP 协议自带(Set-Cookie 响应头 / Cookie 请求头) 浏览器自动收发,无需手写 移动端 APP 不可用;不安全(可禁用);不能跨域
Session 服务端 底层基于 Cookie(JSESSIONID) 数据在服务端,相对安全 集群环境下无法直接用;移动端不可用;可被禁用;不能跨域
令牌 Token 客户端(如 localStorage) 自己实现生成/传递/校验 支持 PC + 移动端;解决集群认证;不占服务端存储 需自己实现整套逻辑

企业开发首选第三种令牌技术,前两种已很少使用。本项目也采用令牌。

3. 跨域速记

三个维度(协议 / IP(域名) / 端口)任意一个不同就是跨域。Cookie 无法跨域,这是 Cookie/Session 方案在前后端分离部署时失效的直接原因。

1
2
3
http://192.168.150.200/login.html  →  https://192.168.150.200/login   协议不同 → 跨域
http://192.168.150.200/login.html → http://192.168.150.100/login IP不同 → 跨域
http://192.168.150.200/login.html → http://192.168.150.200:8080/login 端口不同 → 跨域

4. 集群环境为何 Session 失效

部署多份 Tomcat + 负载均衡时:登录请求落到 Tomcat1 创建 Session,下一次查询请求可能被分到 Tomcat2,Tomcat2 里没有这个 JSESSIONID 对应的 Session → 会话丢失。令牌方案把凭证存在客户端,每次请求自带,天然避开这个问题。


三、JWT 令牌

1. 是什么

JWT(JSON Web Token):一种简洁、自包含的字符串格式,用于通信双方以 JSON 格式安全传输信息,靠数字签名保证可靠性。

  • 简洁:就是一个字符串,可直接放请求参数或请求头。
  • 自包含:可在令牌里存自定义数据(如 id、username),不用查服务端。
  • 本质:把原始 JSON 数据安全封装成字符串,传输时基于 base64 编码(编码 ≠ 加密)。

2. 三段组成(用 . 分隔)

1
2
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko
└── Header(头) ──┘ └── Payload(载荷) ──────────────────────────────┘ └── Signature(签名) ──────────────┘
部分 内容 举例
Header 令牌类型 + 签名算法 {"alg":"HS256","type":"JWT"}
Payload 自定义信息 + 默认信息(如 exp 过期时间) {"id":1,"username":"Tom","exp":...}
Signature Header + Payload + 秘钥,按指定算法计算,防篡改 签名串

关键结论:篡改令牌任意一个字符,解析都会报错令牌过期后解析也报错(非法)。

3. 生成与校验(讲义示例)

依赖:

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

生成 + 校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 生成
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");

String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, "aXRjYXN0") // 签名算法 + 秘钥
.addClaims(claims) // 自定义载荷
.setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000)) // 过期时间
.compact();

// 校验(解析失败就抛异常)
Claims claims = Jwts.parser()
.setSigningKey("aXRjYXN0") // 必须和生成时同一秘钥
.parseClaimsJws(jwt)
.getBody();

两条铁律:

  • 校验用的签名秘钥,必须和生成时配套
  • 解析报错 = 令牌被篡改 或 已过期 = 非法。

四、登录时下发令牌(项目实现)

1. 整体流程

1
2
3
4
5
6
7
8
9
10
11
12
13
浏览器                服务端
│ POST /login {username,password}
│ ─────────────────────────────────► │ EmpService.login 查 emp 表
│ │ 查到 → 生成 JWT 装进 LoginInfo
│ {code:1, data:{id,username,name,token}}
│ ◄───────────────────────────────── │
│ 前端把 token 存到 localStorage
│ │
│ GET /depts Header: token: xxx
│ ─────────────────────────────────► │ Filter/Interceptor 校验 token
│ │ 合法 → 放行到 Controller
│ {code:1, data:[...]}
│ ◄───────────────────────────────── │

2. 关键类与代码

类/所在层 全限定类名 作用
pojo com.zhang.pojo.LoginInfo 登录成功响应封装:idusernamenametoken
utils com.zhang.utils.JwtUtils JWT 工具类:generateToken 生成、parseToken 解析
controller com.zhang.controller.LoginController /login 接口入口,返回 Result.success(loginInfo)Result.error(...)
service com.zhang.service.impl.EmpServiceImpl login(Emp):根据用户名密码查库 → 生成 JWT → 组装 LoginInfo
mapper com.zhang.mapper.EmpMapper getByUsernamePassword(Emp):按 username + password 查询员工

LoginInfo(响应封装):

1
2
3
4
5
6
7
8
9
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginInfo {
private Integer id;
private String username;
private String name;
private String token; // JWT 令牌
}

JwtUtils(项目版本,方法名是 generateToken / parseToken):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JwtUtils {
private static String signKey = "emhhbmctMjAyNg=="; // 签名秘钥
private static long expire = 12 * 60 * 60 * 1000; // 12 小时

public static String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.signWith(SignatureAlgorithm.HS256, signKey)
.compact();
}

public static Map<String, Object> parseToken(String token) {
return Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(token)
.getBody();
}
}

LoginController

1
2
3
4
5
6
7
8
9
@PostMapping("/login")
public Result login(@RequestBody Emp emp) {
log.info("登录请求 emp:{}", emp);
LoginInfo loginInfo = empService.login(emp);
if (loginInfo != null) {
return Result.success(loginInfo);
}
return Result.error("用户名或密码错误");
}

EmpServiceImpl.login(查库 + 生成 token):

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public LoginInfo login(Emp emp) {
Emp e = empMapper.getByUsernamePassword(emp); // select id,username,name from emp where username=? and password=?
if (e != null) {
Map<String, Object> map = new HashMap<>();
map.put("id", emp.getId()); // ⚠ 见下方"易错点":建议改用 e.getId()
map.put("username", emp.getUsername());
String token = JwtUtils.generateToken(map);
return new LoginInfo(e.getId(), e.getUsername(), e.getName(), token);
}
return null;
}

前端拿到 token 后存 localStorage(不是 cookie),后续每个请求在请求头 token 字段带上。


五、统一拦截方案一:Filter 过滤器

1. 是什么

Filter 是 JavaWeb 三大组件(Servlet、Filter、Listener)之一,能把对资源的请求拦下来做通用操作(登录校验、统一编码、敏感字符处理等)。要先经过 Filter,才能访问后面的资源。

2. 入门三步

  1. 定义类实现 Filter 接口,重写 init / doFilter / destroy
  2. 类上加 @WebFilter(urlPatterns = "/*") 配置拦截路径。
  3. 启动类加 @ServletComponentScan 开启 Servlet 组件支持。

三个方法:

方法 调用时机 调用次数
init web 服务器启动创建 Filter 实例时 1 次
doFilter 每次拦截到请求 多次
destroy web 服务器关闭时 1 次

放行chain.doFilter(request, response); —— 不调用就到不了后面的资源。

3. 执行流程(放行前 / 放行后)

1
2
3
4
5
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
System.out.println("放行前逻辑.....");
chain.doFilter(req, resp); // 放行,访问 web 资源
System.out.println("放行后逻辑....."); // 资源处理完回到这里
}

4. 拦截路径配置

拦截路径 urlPatterns 含义
具体路径 /login 只拦截 /login
目录拦截 /emps/* 拦截 /emps 下所有资源
拦截所有 /* 拦截所有资源

5. 过滤器链

多个 Filter 形成链,一个接一个执行;放行后回来时顺序相反。注解配置的 Filter,执行顺序按类名字符串自然排序(AbcFilter 先于 DemoFilter)。

6. 登录校验六步(项目类:TokenFilter,全限定类名 com.zhang.filter.TokenFilter

1
2
3
4
5
6
1. 获取请求 URI
2. 包含 /login → 放行(登录请求例外)
3. 取请求头 token
4. token 为空 → 响应 401
5. 解析 token 失败 → 响应 401
6. 解析成功 → 放行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 项目 TokenFilter 核心逻辑(注意:本项目中此 Filter 未启用,见"项目现状")
String uri = request.getRequestURI().toString();
if (uri.contains("/login")) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
String jwt = request.getHeader("token");
if (jwt == null || jwt.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
return;
}
try {
JwtUtils.parseToken(jwt);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(servletRequest, servletResponse);

六、统一拦截方案二:Interceptor 拦截器

1. 是什么

Interceptor 是 Spring 框架提供的,动态拦截 Controller 方法的执行。作用和 Filter 类似,但只拦 Spring 环境内的资源。

2. 入门两步

  1. 定义类实现 HandlerInterceptor,重写三个方法,加 @Component
  2. 写配置类 WebConfig implements WebMvcConfigurer,重写 addInterceptors 注册拦截器。必须加@Configuration

三个方法:

方法 调用时机 返回值含义
preHandle 目标方法执行前 true 放行;false 不放行(Controller 不执行)
postHandle 目标方法执行后
afterCompletion 视图渲染完毕后,最后执行

3. 注册配置(项目类:WebConfig,全限定类名 com.zhang.config.WebConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TokenInterceptor tokenInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**") // 拦截所有
// .excludePathPatterns("/login") // 也可用排除方式放过登录
;
}
}

4. 拦截路径配置

拦截路径 含义 能匹配 不能匹配
/* 一级路径 /depts、/login /depts/1
/** 任意级路径 /depts、/depts/1、/depts/1/2
/depts/* /depts 下一级 /depts/1 /depts/1/2、/depts
/depts/** /depts 下任意级 /depts、/depts/1、/depts/1/2 /emps/1

排除登录请求有两种写法:① 拦截器里 uri.contains("login") 判断放行(项目用法);② 配置里 excludePathPatterns("/login")

5. 登录校验 Interceptor(项目类:TokenInterceptor,全限定类名 com.zhang.interceptor.TokenInterceptor

逻辑与 Filter 完全一致,只是返回 boolean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String uri = request.getRequestURI().toString();
if (uri.contains("/login")) {
return true; // 登录请求放行
}
String jwt = request.getHeader("token");
if (jwt == null || jwt.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
try {
JwtUtils.parseToken(jwt);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return true;
}
}

七、Filter vs Interceptor 对比(高频考点)

对比项 Filter 过滤器 Interceptor 拦截器
接口规范 实现 jakarta.servlet.Filter 实现 HandlerInterceptor
拦截范围 拦截所有资源(包括静态资源、JSP 等) 只拦 Spring 环境中的资源(Controller)
归属 JavaWeb 三大组件(Servlet 规范) Spring 框架提供
配置方式 @WebFilter + @ServletComponentScan WebMvcConfigurer.addInterceptors
核心方法 doFilter(放行 chain.doFilter preHandle 返回 boolean
执行时机 DispatcherServlet 之前 DispatcherServlet 之后、Controller 之前

执行顺序(重要):

1
2
浏览器 → Filter(放行前) → DispatcherServlet → Interceptor.preHandle → Controller
→ Interceptor.postHandle → Interceptor.afterCompletion → Filter(放行后) → 浏览器

本项目二选一用了 Interceptor,Filter 仅作示例保留(见下)。


八、项目现状速查(重点!)

复习时最容易把”示例代码”和”项目真正生效的校验链路”搞混,这里专门列清。

1. 实际生效的校验链路

1
请求 → TokenInterceptor.preHandle (校验 token) → Controller → Service → Mapper
  • 校验组件:TokenInterceptorcom.zhang.interceptor.TokenInterceptor,加了 @Component
  • 注册位置:WebConfigcom.zhang.config.WebConfigaddPathPatterns("/**") 注册拦截器)

2. Filter 是”保留示例”,未启用

TokenFiltercom.zhang.filter.TokenFilter)里 @WebFilter(urlPatterns = "/*") 被注释掉了,也没有 @Component,所以不会生效。讲义里也明确:”登录校验的过滤器和拦截器,我们只需要使用其中的一种就可以了。”

3. 项目与讲义的命名差异

讲义命名 项目命名 说明
JwtUtils.generateJwt JwtUtils.generateToken 同功能,方法名不同
JwtUtils.parseJWT JwtUtils.parseToken 同功能,方法名不同
empMapper.getUsernameAndPassword empMapper.getByUsernamePassword 同 SQL,方法名不同
LoginInfo 字段 token 一致

4. 项目涉及文件清单

全限定类名 / 类 本节做了什么
pojo com.zhang.pojo.LoginInfo 新增:登录响应封装(idusernamenametoken
utils com.zhang.utils.JwtUtils 新增:JWT 工具类(generateTokenparseToken
controller com.zhang.controller.LoginController 新增:POST /login 接口
service com.zhang.service.impl.EmpServiceImpl 新增 login(Emp):查库 → 生成 JWT → 组装 LoginInfo
mapper com.zhang.mapper.EmpMapper 新增 getByUsernamePassword(Emp):按用户名密码查询
interceptor com.zhang.interceptor.TokenInterceptor 新增:token 校验拦截器(当前生效
filter com.zhang.filter.TokenFilter 新增:token 校验过滤器(未启用,仅示例)
config com.zhang.config.WebConfig 新增:注册 TokenInterceptor,拦截 /**

九、易错点 & 复习提醒

  1. 生成 token 时取 id 的来源:项目 EmpServiceImpl.login 里写的是 map.put("id", emp.getId()),但 emp 是请求体对象,登录时前端不会传 id,所以这里的 id 实际是 null。更稳的写法是用查询结果 e.getId()(讲义示例就是这么写的)。不影响 token 是否生成,但 token 载荷里的 id 会丢。

  2. 登录请求必须放行:拦截器/过滤器第一件事就是判断 uri.contains("/login") 放行,否则永远登不进去拿不到 token。

  3. token 的传递字段是请求头 token,不是 Authorization,也不是 cookie。前后端要约定一致(本项目前端在 token 头里带)。

  4. 401 状态码:未登录、token 为空、token 解析失败,都返回 SC_UNAUTHORIZED(401)。前端拿到 401 会自动跳登录页。

  5. JWT 秘钥必须配套:生成和解析用同一个 signKey,换了就全部失效。

  6. JWT 不是加密:Payload 是 base64 编码,能直接解码出来,所以别往里放密码等敏感信息。签名只是防篡改,不是防查看。

  7. Filter 和 Interceptor 不要同时启用做同一件事:本项目 Filter 注释掉了,只用 Interceptor。两个都开会导致重复校验。

  8. @WebFilter 方式需要 @ServletComponentScan;如果用 @Component + FilterRegistrationBean 则不需要。本项目 Interceptor 走的是 Spring 的 WebMvcConfigurer 注册方式。

  9. 拦截路径 /* vs /**:Filter 用 /*(Servlet 规范);Interceptor 用 /**(Spring Ant 风格)。两者含义不同,别混。


十、一图回顾整条链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌───────── 登录 ─────────┐
│ POST /login │
│ ↓ │
│ LoginController │
│ ↓ │
│ EmpServiceImpl.login │
│ ├─ EmpMapper 查库 │
│ └─ JwtUtils 生成 token│
│ ↓ │
│ 返回 LoginInfo{token} │
│ ↓ │
│ 前端存 localStorage │
└────────────────────────┘

┌──────── 后续请求 ──────┐
│ 请求头带 token │
│ ↓ │
│ TokenInterceptor │
│ .preHandle │
│ ├─ /login → 放行 │
│ ├─ 无 token → 401 │
│ ├─ 解析失败 → 401 │
│ └─ 合法 → return true│
│ ↓ │
│ Controller → Service │
│ ↓ │
│ 返回结果 │
└────────────────────────┘