登录验证实战
本章是 Spring MVC 教程中"拦截器、过滤器与跨域"章节的端到端实战。在理解了 HandlerInterceptor 的 preHandle / postHandle / afterCompletion 三个回调点后,本章通过一个完整的登录验证系统,展示拦截器在真实项目中的落地方式。这不是机制讲解,而是"从需求到代码"的完整闭环。
定义与作用
登录验证是 Web 应用最基础的访问控制机制。它的核心逻辑是:对于需要保护的资源,检查当前用户是否已登录;未登录则阻止访问,并重定向到登录页。
在 Spring MVC 中,登录验证通常由 HandlerInterceptor 实现,而非 Servlet Filter。原因在于:
- Interceptor 能访问 Spring 容器:可以注入 Service、读取配置文件,与 Spring 生态无缝集成
- Interceptor 能获取 Controller 信息:通过
HandlerMethod知道即将访问的是哪个类的哪个方法,可以做细粒度控制(如某些方法免登录) - Interceptor 与 Spring MVC 生命周期绑定:preHandle 在 Controller 之前执行,postHandle 在视图渲染之前执行,afterCompletion 在请求结束后执行,天然适合"检查 → 放行/拦截 → 清理"的流程
本实战的场景是:员工管理系统的大部分页面需要登录后才能访问,但登录页、注册页、静态资源(CSS/JS/图片)必须对未登录用户开放。
生活类比:办公大楼的门禁系统
想象一栋办公大楼:
- Interceptor 是大堂的门禁闸机:每个人进入办公区(Controller)之前,必须经过闸机检查。闸机刷员工卡(检查 Session 中的登录用户),合法则开门放行,非法则引导到访客登记处(登录页)。
- Filter 是大楼外围的围墙:围墙在闸机更外层,负责检查是否携带危险物品(编码过滤、XSS 防护)。围墙不知道你是去 3 楼还是 5 楼,它只负责"能不能进大楼"。
- 登录页是访客登记处:未带员工卡的人被引导到这里,填写信息(输入用户名密码),验证通过后领取临时门禁卡(写入 Session)。
- 登出是交还门禁卡:离开大楼时,在闸机处刷卡注销(清除 Session),下次再来需要重新登记。
这个实战的关键在于:闸机(Interceptor)知道办公区布局(HandlerMapping),能精确控制哪些楼层要刷卡、哪些楼层不用刷。
核心原理
登录验证拦截流程
流程解读:
- 首次访问(未登录):用户访问
/home,LoginInterceptor 的 preHandle 检查 Session,发现没有user属性,返回 false 并发送重定向到/login。 - 登录提交:用户在登录页填写表单,POST 到
/login。LoginController 验证成功后,将 User 对象写入 Session,然后重定向到/home。 - 再次访问(已登录):用户再次访问
/home,Interceptor 检查 Session 发现user存在,返回 true 放行,HomeController 正常执行。 - 登出:用户访问
/logout,LoginController 调用session.invalidate()清除整个 Session,然后重定向到登录页。
完整示例
场景
飞翔科技的员工管理系统已上线运行。CTO 大翔发现,未登录用户直接输入 URL(如 /employees)就能访问敏感数据,存在严重安全隐患。他要求:
- 所有功能页面必须登录后才能访问
- 登录页(
/login)、注册页(/register)、静态资源(/css/**、/js/**、/images/**)不能拦截 - 登录验证用 Session 机制,登出后 Session 完全清除
- 未登录用户访问受保护页面时,自动跳转到登录页,登录成功后自动回到原页面
架构师白歌设计"Interceptor + Session + 重定向"方案。小崔负责 LoginInterceptor 和 LoginController,黄俪负责登录页面和首页,李眉负责 Session 超时配置。
拦截路径配置表
| 路径 | 是否拦截 | 说明 |
|---|---|---|
/login | ❌ 不拦截 | 登录页本身不能要求登录 |
/register | ❌ 不拦截 | 注册页同理 |
/css/** | ❌ 不拦截 | 静态资源,无需登录 |
/js/** | ❌ 不拦截 | 静态资源,无需登录 |
/images/** | ❌ 不拦截 | 静态资源,无需登录 |
/api/public/** | ❌ 不拦截 | 公开 API(如验证码) |
/** | ✅ 拦截 | 其余所有路径都需要登录 |
小崔的 LoginInterceptor
// LoginInterceptor.java
package com.feixiang.web.interceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
@Component
public class LoginInterceptor implements HandlerInterceptor {
/**
* 在 Controller 执行前检查登录状态
*/
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession();
Object user = session.getAttribute("currentUser");
if (user == null) {
// 未登录:保存当前请求 URL,登录成功后跳回来
String requestUri = request.getRequestURI();
String queryString = request.getQueryString();
if (queryString != null) {
requestUri += "?" + queryString;
}
session.setAttribute("redirectAfterLogin", requestUri);
// 重定向到登录页
response.sendRedirect(request.getContextPath() + "/login");
return false; // 拦截,不执行后续 Controller
}
// 已登录:放行
return true;
}
}
代码分析:
preHandle返回true表示放行,返回false表示拦截- 未登录时,将用户原本想访问的 URL 存入 Session(key 为
redirectAfterLogin),登录成功后读取并跳转 response.sendRedirect()发送 302 重定向,浏览器地址栏会变为/login- 注意
request.getContextPath(),防止应用部署在子路径时重定向地址错误
小崔的 WebMvcConfigurer 配置
// WebConfig.java
package com.feixiang.web.config;
import com.feixiang.web.interceptor.LoginInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns("/login") // 排除登录页
.excludePathPatterns("/register") // 排除注册页
.excludePathPatterns("/css/**") // 排除 CSS
.excludePathPatterns("/js/**") // 排除 JS
.excludePathPatterns("/images/**") // 排除图片
.excludePathPatterns("/api/public/**"); // 排除公开 API
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 配置静态资源映射,确保 /css/style.css 能正确找到文件
registry.addResourceHandler("/css/**")
.addResourceLocations("classpath:/static/css/");
registry.addResourceHandler("/js/**")
.addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/images/**")
.addResourceLocations("classpath:/static/images/");
}
}
配置分析:
addPathPatterns("/**")表示拦截所有请求excludePathPatterns按顺序排除不需要拦截的路径- 静态资源映射确保
/css/style.css被 DispatcherServlet 正确处理(或绕过 DispatcherServlet 由容器直接服务) - 拦截器注册顺序很重要:如果有多个 Interceptor,先注册的先执行 preHandle,后执行 postHandle/afterCompletion
小崔的 LoginController
// LoginController.java
package com.feixiang.web;
import com.feixiang.model.User;
import com.feixiang.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpSession;
@Controller
public class LoginController {
@Autowired
private UserService userService;
/**
* 显示登录页
*/
@GetMapping("/login")
public String loginPage() {
return "login"; // 返回 login.html 视图
}
/**
* 处理登录提交
*/
@PostMapping("/login")
public String doLogin(@RequestParam String username,
@RequestParam String password,
HttpSession session,
Model model) {
// 验证用户名密码
User user = userService.authenticate(username, password);
if (user == null) {
// 登录失败:回到登录页,显示错误信息
model.addAttribute("error", "用户名或密码错误");
return "login";
}
// 登录成功:写入 Session
session.setAttribute("currentUser", user);
// 如果有登录前想访问的页面,跳转回去;否则去首页
String redirectUrl = (String) session.getAttribute("redirectAfterLogin");
if (redirectUrl != null) {
session.removeAttribute("redirectAfterLogin");
return "redirect:" + redirectUrl;
}
return "redirect:/home";
}
/**
* 登出
*/
@GetMapping("/logout")
public String logout(HttpSession session) {
// 清除整个 Session,包括 currentUser 和 redirectAfterLogin
session.invalidate();
return "redirect:/login";
}
}
代码分析:
userService.authenticate()封装了用户名密码验证逻辑(本教程不展开 Service 层)- 登录失败时返回
"login"(逻辑视图名),配合model.addAttribute("error", ...)在页面显示错误 - 登录成功时优先跳转到
redirectAfterLogin(用户原本想访问的页面),提升用户体验 session.invalidate()比session.removeAttribute("currentUser")更彻底,清除所有 Session 数据防止残留
黄俪的登录页面
<!-- login.html (Thymeleaf) -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>登录 - 员工管理系统</title>
<link rel="stylesheet" th:href="@{/css/login.css}"/>
</head>
<body>
<div class="login-box">
<h1>员工管理系统</h1>
<!-- 错误提示 -->
<div th:if="${error}" class="error-msg" th:text="${error}">
用户名或密码错误
</div>
<form th:action="@{/login}" method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" required/>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required/>
</div>
<button type="submit">登录</button>
</form>
<p class="register-link">
还没有账号?<a th:href="@{/register}">立即注册</a>
</p>
</div>
</body>
</html>
黄俪的设计说明:
- 登录页引用了
/css/login.css,由于 WebConfig 中排除了/css/**的拦截,未登录用户也能正常加载样式 - 表单提交到
/login,POST 请求同样被 Interceptor 排除(因为 excludePathPatterns 包含/login) - 错误提示使用 Thymeleaf 的条件渲染
th:if="${error}",登录失败时才显示
李眉的 Session 配置
# application.yml
server:
servlet:
session:
timeout: 30m # Session 30 分钟无操作后过期
spring:
mvc:
static-path-pattern: /static/**
李眉的运维笔记:
- 生产环境建议 Session 超时设为 30 分钟,平衡安全性与用户体验
- 如果部署集群,需要配置 Spring Session + Redis,实现 Session 共享(否则负载均衡后用户可能频繁掉线)
- 登录密码必须加密存储(BCrypt),本示例为简化未展示,生产环境严禁明文比对
易错场景与面试考点
误区一:Interceptor 里直接返回视图名
错误代码:
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (session.getAttribute("user") == null) {
// ❌ 错误:Interceptor 里不能返回视图名,只能操作 response
request.setAttribute("error", "请登录");
request.getRequestDispatcher("/login").forward(request, response);
return false;
}
return true;
}
问题:虽然 forward 能工作,但 Interceptor 的职责是"拦截或放行",不是"渲染视图"。如果登录页本身需要 Controller 准备数据(如显示验证码),forward 会跳过 Controller 直接找视图,导致页面数据缺失。
纠正:Interceptor 只负责重定向到登录页,登录页的数据准备由 LoginController 负责:
// Interceptor:只做判断和重定向
response.sendRedirect("/login");
return false;
// LoginController:负责准备登录页所需的所有数据
@GetMapping("/login")
public String loginPage(Model model) {
model.addAttribute("captcha", generateCaptcha());
return "login";
}
误区二:excludePathPatterns 配置遗漏
错误配置:
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login");
// ❌ 遗漏了 /css/**、/js/**、/images/**
后果:未登录用户访问登录页时,页面样式全部丢失(因为 /css/login.css 被拦截,重定向到 /login,形成循环)。
纠正:必须完整排除所有静态资源和公开路径。建议按功能模块分组管理排除规则:
private static final String[] EXCLUDE_PATHS = {
"/login", "/register", "/logout",
"/css/**", "/js/**", "/images/**", "/fonts/**",
"/api/public/**", "/error"
};
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(EXCLUDE_PATHS);
误区三:Session 属性名不一致
错误代码:
// LoginController 里写入
session.setAttribute("user", user);
// LoginInterceptor 里读取
Object user = session.getAttribute("currentUser"); // ❌ key 不一致,永远读不到
纠正:定义常量统一管理 Session key:
public final class SessionKeys {
public static final String CURRENT_USER = "currentUser";
public static final String REDIRECT_AFTER_LOGIN = "redirectAfterLogin";
private SessionKeys() {} // 禁止实例化
}
面试高频:Interceptor 和 Filter 都能做登录验证,有什么区别?该选哪个?
标准回答:
| 维度 | Filter(Servlet 规范) | Interceptor(Spring MVC) |
|---|---|---|
| 执行时机 | DispatcherServlet 之前 | DispatcherServlet 之后,Controller 之前 |
| 能否访问 Spring 容器 | 不能直接注入 Bean | 可以 @Autowired 注入 Service |
| 能否获取 Controller 信息 | 不能,只能看到 URL | 能,通过 HandlerMethod 获取类名、方法名、注解 |
| 配置方式 | web.xml 或 @WebFilter | WebMvcConfigurer.addInterceptors() |
| 执行链控制 | 通过 FilterChain 控制 | 通过返回值 true/false 控制 |
选择建议:
- 如果验证逻辑需要调用 Spring 管理的 Service(如查询数据库验证 Token),用 Interceptor
- 如果验证逻辑是纯 Servlet 层操作(如检查请求头签名、IP 白名单),用 Filter 性能更好
- 登录验证通常需要查询用户表、权限表,属于业务逻辑,因此推荐用 Interceptor
小结
登录验证实战展示了 HandlerInterceptor 在真实项目中的完整落地路径:Interceptor 检查 Session → 未登录则重定向 → LoginController 验证并写入 Session → 已登录则放行 → Logout 清除 Session。整个流程中,Interceptor 负责"把关",Controller 负责"办事",Session 负责"记住状态",三者分工明确。
实现时务必注意三个易错点:excludePathPatterns 必须完整排除静态资源和公开路径;Interceptor 里只做重定向,不做视图渲染;Session key 必须全局统一。同时,生产环境还需要考虑 Session 超时、集群共享、密码加密等运维问题。
本章与全局的关系:本章是 HandlerInterceptor 机制的实战落地。下一章"过滤器(Filter)与跨域处理"将对比讲解 Servlet Filter 的使用场景,以及 CORS 跨域配置的实现方式。