说实话,只要是做Java后端的朋友,基本都绕不开登录认证这块。早期我还在用拦截器加Session手动判断用户是否登录,后来项目切到Spring Boot,就老老实实把Spring Security用起来了。刚开始确实有点劝退,过滤器链、认证管理器、UserDetailsService这些概念堆在一起,光看官方文档就能绕晕。但你真的把它跑通一遍,把登录、鉴权、会话管理这些动作在脑子里串成一条线之后,就会发现Spring Security并没有传说中那么高不可攀,它只是把安全这件事拆得太细了,初学者容易被概念淹没。
这篇文章我就从自己实际项目的角度出发,把Spring Security做登录认证的完整思路、核心配置、实操步骤和踩坑经验一次性讲清楚。不管你是刚接触Spring Security的新手,还是已经写过几个Demo但始终没搞懂过滤器链到底怎么跑的老手,这篇文章都值得你认真看一看。
1. 整体设计与思路拆解
1.1 Spring Security到底是怎么工作的
先别急着写代码,我们得先把Spring Security的“运行逻辑”盘明白。大多数人在初学阶段遇到的最大阻碍,是不知道请求到底经过了哪些环节才到达Controller。
这里我打个比方。Spring Security像是一个安保系统,而过滤器链就是一条安检通道。每个请求进入应用时,都要经过这条通道。通道里装了各种功能的安检员,有的负责判断你有没有带“门票”(Session或Token),有的负责把“门票”换成“身份信息”(认证),有的负责检查“你够不够格进某个房间”(授权),最后才把请求放到Controller这个“接待室”里。
这个通道在Spring Security里叫作FilterChain,核心处理器是UsernamePasswordAuthenticationFilter等一组过滤器。对于表单登录来说,流程大致如下:
- 请求带着用户名密码到达登录接口。
UsernamePasswordAuthenticationFilter把用户名密码包装成一个Authentication对象。- 认证管理器
AuthenticationManager拿到这个对象,调用UserDetailsService.loadUserByUsername()去查用户信息。 - 查到用户后,用密码编码器
PasswordEncoder比对密码。 - 比对成功,生成一个完整的
Authentication对象,存进SecurityContext。 - 后续请求再进来时,
SecurityContextPersistenceFilter会从Session里恢复这个认证信息,让用户保持登录状态。
这么一看其实就清晰了:认证的本质是把“你声称自己是谁”变成“我们确认你就是谁”。Spring Security替你把这个过程标准化了,你要做的就是实现UserDetailsService、配置PasswordEncoder、规定哪些请求要认证、哪些请求可以放行。
1.2 为什么用Spring Security而不是自己写拦截器
有的朋友会问,我就做一个简单的登录功能,用拦截器判一下Session不就行了吗?为什么非得引入Spring Security这个“庞然大物”?
这个观点我以前也有过。早期项目确实这么做,登录接口生成一个Session,写一个HandlerInterceptor拦截需要登录的路径,检查Session里有没有用户信息。应付小项目完全没问题,但一旦业务变复杂,就会暴露出几个痛点:
- 密码加密方案容易搞得五花八门,有人用MD5加盐,有人用SHA,维护起来很头疼。
- Session管理要靠自己写超时逻辑,并发登录控制、固定会话攻击防护这些都得自己造轮子。
- 授权逻辑一旦变复杂,拦截器里的if-else就会膨胀成一大坨。
- 如果项目还需要接入第三方登录、记住我、CSRF防护,自己写的拦截器基本扛不住。
Spring Security把这些安全领域的通用问题全部内置了,你要做的只是“填空配置”。它在你手里的价值不是写代码,而是提供一套已经验证过的安全模型,让应用默认处于安全状态,配置不当最多是功能受限,但不容易出现明显的安全漏洞。基于这个认知现,我后来的项目只要涉及用户体系,默认就上Spring Security。
1.3 项目场景与方案选型
我这次要讲的实战案例,是一个典型的Web服务端渲染项目,使用的是Spring Boot 2.7和Spring Security 5.7版本。为什么提版本?因为Spring Security 5.x和6.x的配置写法有不小差异,比如6.x里WebSecurityConfigurerAdapter被移除了,改成SecurityFilterChain的Bean方式。网上搜到的很多老教程用的是5.x的写法,直接复制到6.x项目里大概率报错,这个大家要注意区分。
我的场景是:
- 系统是传统的服务端渲染,前端页面由模板引擎输出,没有做前后端分离。
- 登录后使用Session维持会话,也就是默认的登录态机制。
- 需要放行登录页、注册页、静态资源;其他所有请求都要求认证。
- 用户角色简单,目前只需要区分普通用户和管理员,授权用
hasAuthority控制。
选型思路上,没有引入JWT是因为当前项目不需要跨域、不需要移动端对接,服务端渲染用Session是成本最低、最不容易出幺蛾子的方案。等哪天要拆前后端了,再换Token体系也不迟。
2. 核心细节解析与实操要点
2.1 引入依赖与基础配置
写代码之前先把依赖加进来。用Maven的话,在pom.xml里引入:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>就这么一个依赖,Spring Boot会自动配置一套默认的安全策略。启动项目后你会发现,所有接口都被保护起来了,访问任意页面都会跳转到一个默认的登录页,用户名是user,密码是启动日志里随机生成的那一串。
这套默认策略的目的是让应用“开箱即安全”,但实际项目显然不能这么用。我们需要覆盖默认配置,自定义登录逻辑。这里要记住一个核心原则:你每重写一个配置时,要想清楚原来默认的配置替你做了什么,别把安全底线给弄丢了。
2.2 核心安全配置类
在Spring Security 5.7之后,官方推荐的写法是定义一个SecurityFilterChain的Bean。我不再推荐继承WebSecurityConfigurerAdapter,那个类已经过时了,新项目直接上SecurityFilterChain。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/register", "/css/**", "/js/**", "/images/**").permitAll() .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .defaultSuccessUrl("/index", true) .failureUrl("/login?error") .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") ) .rememberMe(remember -> remember .tokenValiditySeconds(1209600) .key("rememberMeKey") ) .sessionManagement(session -> session .sessionFixation().migrateSession() .maximumSessions(1) .expiredUrl("/login?expired") ); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }我来逐段拆解一下这段配置的含义。
authorizeHttpRequests是授权规则,注意和早期版本的antMatchers区分。这里的requestMatchers指定路径匹配规则,permitAll()表示这些路径不需要认证就能访问,hasAuthority("ROLE_ADMIN")表示只有拥有管理员权限的人才能访问/admin/**,anyRequest().authenticated()表示除了上面明确定义的规则,其他任何请求都需要登录。
formLogin是表单登录配置。loginPage("/login")指定自定义登录页的地址,loginProcessingUrl("/login")是表单提交的用户名密码要POST到的地址。这里有个容易出错的点:登录页的GET请求和登录表单提交的POST请求可以指向同一个URL,框架会根据请求方法自动区分。
logout配置了退出登录的地址和退出成功后跳转的页面。rememberMe表示开启“记住我”功能,设置Token有效期两周。sessionManagement里做了两件事:启用会话固定攻击防护,同时限制同一个用户的登录会话数最多1个,防止账号多人共用。
2.3 UserDetailsService与用户存储设计
Spring Security只关心“如何认证”,不关心“用户存在哪”。所以我们要自己实现UserDetailsService,告诉框架从哪里查用户、怎么把数据库里的用户转换成框架认识的UserDetails。
我这里用一个简单的内存用户来做示范,实际项目换成查数据库的逻辑即可。
@Service public class UserDetailsServiceImpl implements UserDetailsService { private final Map<String, User> userStore = new ConcurrentHashMap<>(); public UserDetailsServiceImpl() { // 模拟数据库中的用户数据,密码为明文加密后的BCrypt值 userStore.put("admin", new User("admin", passwordEncoder().encode("admin123"), List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); userStore.put("user", new User("user", passwordEncoder().encode("user123"), List.of(new SimpleGrantedAuthority("ROLE_USER")))); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userStore.get(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return user; } private PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这里要重点解释一下UserDetails里那三个方法的关系。很多新手会搞混getAuthorities()和getPassword()、getUsername()。其实框架的认证逻辑是这样的:登录时拿到用户输入的用户名,调用loadUserByUsername()返回一个UserDetails对象,这个对象里包含了数据库中存储的密码和该用户的权限列表。接下来DaoAuthenticationProvider会把用户输入的密码和UserDetails中存储的密码交给PasswordEncoder去比对,比对通过则认证成功。认证成功后,框架会创建一个Authentication对象,把UserDetails里的权限信息放进去,供后续授权判断使用。
所以设计上有一条铁律:数据库里不要存明文密码,存BCrypt加密后的密文。UserDetails里的密码是密文,永远不会拿明文来比对,而是通过PasswordEncoder.matches()方法用密文校验明文。
2.4 PasswordEncoder的选择逻辑
Spring Security的PasswordEncoder接口有三个关键方法:encode()用来给密码加密,matches()用来校验原始密码和加密后的密文是否匹配,upgradeEncoding()用来判断是否需要升级编码方案。
比较常见的几个实现:
| 实现类 | 加密方式 | 是否推荐 |
|---|---|---|
BCryptPasswordEncoder | BCrypt强哈希,自带盐,抗彩虹表 | 强烈推荐 |
Argon2PasswordEncoder | Argon2算法,内存密集型,抗GPU破解 | 推荐 |
Pbkdf2PasswordEncoder | PBKDF2标准,可指定迭代次数 | 可以接受 |
DelegatingPasswordEncoder | 默认方案,支持多种前缀格式 | 适合老项目平滑升级 |
NoOpPasswordEncoder | 明文存储,没有任何加密 | 绝对不推荐 |
我自己的项目统一用BCryptPasswordEncoder,原因很简单:成熟、稳定、使用最广泛,网上资料多,团队协作时大家都能快速上手。BCrypt一个很大的特点是每次加密同一个明文得到的密文都不一样,因为它在内部自动融入了随机盐,这让彩虹表攻击完全没有施展空间。
2.5 自定义登录页面与控制层
Spring Security自带的默认登录页丑得没法看,而且和自己的项目风格不搭,所以实际项目中必须自定义。我在static目录下放了一个登录页面,表单长这样:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>登录</title> </head> <body> <form action="/login" method="post"> <div> <label>用户名:<input type="text" name="username" required></label> </div> <div> <label>密码:<input type="password" name="password" required></label> </div> <div> <label><input type="checkbox" name="remember-me"> 记住我</label> </div> <div th:if="${param.error}" style="color: red;">用户名或密码错误</div> <div th:if="${param.logout}" style="color: green;">已成功退出登录</div> <button type="submit">登录</button> </form> </body> </html>需要注意几个点:
- 表单字段的
name默认必须是username和password,这是Spring Security读取参数的默认键名。你要是前端传的是loginName,就得通过usernameParameter("loginName")和passwordParameter("passwd")去改映射。 - 表单的
action要指向loginProcessingUrl里配置的地址,也就是/login。 - CSRF防护默认是开启的,所以在Thymeleaf模板里,表单需要额外带一个
_csrf隐藏域。Thymeleaf很智能,如果直接用Thymeleaf的th:action来渲染表单,它会自动加上这个隐藏域,这里直接写HTML的话就必须手动加,否则登录请求会被拒绝。
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">Controller这边就非常简单了,只需要提供登录页、首页等页面的跳转逻辑:
@Controller public class PageController { @GetMapping("/login") public String loginPage() { return "login"; } @GetMapping("/index") public String indexPage(Model model, @AuthenticationPrincipal UserDetails userDetails) { model.addAttribute("username", userDetails.getUsername()); return "index"; } }@AuthenticationPrincipal是Spring Security提供的一个注解,用来从当前认证信息中提取UserDetails对象。在Controller里拿当前登录用户的信息时,用这个注解比手动从SecurityContextHolder里取要干净得多。
3. 实操过程与核心环节实现
3.1 从零搭建一个带登录认证的Demo项目
前面概念讲得差不多了,现在我们来完整走一遍实操。我这里从零开始搭建一个最基础的Demo项目。
第一步,初始化项目。用Spring Initializr创建一个Spring Boot项目,Java版本选8或11都可以,项目类型选Maven。依赖选择Spring Web、Thymeleaf、Spring Security、Spring Data JPA(如果要用数据库)或Spring JDBC。如果只是做纯认证演示,不接数据库也完全没问题,用户数据放在内存里。
第二步,引入安全依赖后,创建一个SecurityConfig配置类。配置文件我上面已经写了,这里不再重复贴代码。
第三步,实现用户查询服务。如果是纯内存用户,参考上文UserDetailsServiceImpl即可。如果要接MySQL,写法也差不多,替换用户查询逻辑就行。这里我补充一个数据库版本的查询实现:
@Service public class DatabaseUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public DatabaseUserDetailsService(UserMapper userMapper) { this.userMapper = userMapper; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } List<GrantedAuthority> authorities = user.getRoles().stream() .map(role -> new SimpleGrantedAuthority(role.getRoleCode())) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities); } }第四步,把用户注册时密码加密的代码补上。注册接口里千万不能明文存密码。
@Service public class UserService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; } public void register(String username, String rawPassword, String roleCode) { SysUser user = new SysUser(); user.setUsername(username); user.setPassword(passwordEncoder.encode(rawPassword)); userMapper.insert(user); // 插入角色关联表... } }第五步,配置登录页面和登录提交地址。页面放哪里都行,只要能被模板引擎解析到。
第六步,启动项目,用浏览器访问登录页,输入内存用户的用户名密码,验证登录是否成功。
3.2 在Security中配置登录Remember-Me
“记住我”功能在Spring Security里实现起来很轻松。我在上面的配置里已经写了两行配置,它背后的原理是利用Cookie存储一个经过签名的Token,用户再次访问时通过Token恢复登录状态。
这里我多说明一下配置细节:
.rememberMe(remember -> remember .tokenValiditySeconds(1209600) .key("rememberMeKey") )tokenValiditySeconds是Token的有效时间,单位是秒,1209600秒等于14天。key是签名用的密钥,生产环境下不要硬编码在这里,应该放到配置中心或者环境变量里。
光配置了rememberMe还不够,登录表单里必须提交一个remember-me参数,参数值必须为true、1、yes或on。也就是我在刚才的HTML表单里写的那一行checkbox。如果不写这个参数,RememberMeAuthenticationFilter是不会生效的。
这里有一个实际体验上的坑:如果用户在浏览器里勾选了记住我,登录成功后在未点击“退出登录”的情况下直接关闭浏览器,再重新打开访问应用,会话仍然是有效的,因为RememberMe直接种了Cookie。但如果用户点击了“退出登录”,后台会清理掉这个Cookie。Spring Security的默认退出逻辑已经处理了这件事,不需要额外关心。
3.3 授权规则与访问控制配置
授权控制是登录认证之后必然会涉及的下一层需求。在Spring Security里有两种控制方式:注解控制和URL控制。
URL控制在SecurityFilterChain里配置,适合全局性的路径治理,比如/admin/**只允许管理员访问。注解控制则更适合方法级别的细粒度权限判断。
要在项目里启用注解控制,需要额外加一个@EnableMethodSecurity注解(Spring Security 5.6之后)或者@EnableGlobalMethodSecurity(老版本)。加上之后,就可以在Controller方法或者Service方法上直接标注解:
@RestController @RequestMapping("/admin") public class AdminController { @GetMapping("/dashboard") @PreAuthorize("hasAuthority('ROLE_ADMIN')") public String dashboard() { return "管理后台首页"; } }@PreAuthorize会在方法执行前检查权限,不满足就抛出AccessDeniedException,由框架统一处理为403响应。
我用这两种方式做一下对比:
| 控制方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| URL规则 | 路径级统一管控 | 配置集中,一目了然,适合治理外部访问 | 无法处理同一个URL根据参数不同权限不同 |
| 方法注解 | 业务方法级精细控制 | 精确灵活,和业务代码在一起 | 配置分散,难以统一排查 |
我在项目里的策略是:基础访问用URL规则兜底,特殊场景用方法注解细化。比如所有/admin/**路径必须管理员权限,这是URL规则;某些操作比如“导出数据”要求特定角色,这是方法注解。
3.4 登录成功与失败的处理策略
默认情况下,表单登录成功后框架会跳转到defaultSuccessUrl指定的地址,失败则重定向到failureUrl。但如果项目需要做一些额外动作,比如记录登录日志、统计失败次数,这两种默认行为就不够用了。
Spring Security允许通过自定义AuthenticationSuccessHandler和AuthenticationFailureHandler来接管登录结果。
@Component public class LoginSuccessHandler implements AuthenticationSuccessHandler { private final LoginLogService loginLogService; public LoginSuccessHandler(LoginLogService loginLogService) { this.loginLogService = loginLogService; } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); loginLogService.recordLoginSuccess(userDetails.getUsername(), request.getRemoteAddr()); String targetUrl = determineTargetUrl(request, response); response.sendRedirect(targetUrl); } private String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) { // 判断来源页面,如果没有target参数就跳到首页 String target = request.getParameter("target"); return StringUtils.hasText(target) ? target : "/index"; } }然后在SecurityConfig里接上:
.formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .successHandler(loginSuccessHandler) .failureHandler(loginFailureHandler) )失败处理器里可以记录失败次数,甚至做账号锁定策略。我曾经在项目里接过一个需求:连续输错5次密码,锁定账号15分钟。实现思路就是在AuthenticationFailureBadCredentialsEvent事件里做计数和锁定持久化。这里先不展开,后面讲事件监听时再说。
4. 常见问题与排查技巧实录
4.1 登录成功但访问页面还是跳登录页
这个问题我在初级项目里见过太多次了,还被问过很多次。表现是输入用户名密码后正常进了首页,但刷新一下或者访问另一个页面,又被弹回登录页。
排查思路要从Session和Cookie两个维度来想。Spring Security默认把认证信息存在Session里,同时依赖Cookie里的JSESSIONID来识别浏览器。如果登录成功但跳转后失效,优先排查几个点:
- 应用中是否配置了
server.servlet.session.timeout,如果超时时间过短(比如1分钟),刷新一下就没了。 - 是不是用了前后端分离的架构,但配置里还开着CSRF防护,导致Ajax请求带不了CSRF Token,被框架拦截。
- 是不是自己写的过滤器或者拦截器把Session清掉了。这种问题在接口层常见,有人会在请求处理结束时调用
session.invalidate()。 - 多实例部署时,负载均衡器没有配置Session保持,导致每次请求落到不同实例,Session不共享。
服务端渲染项目里,最常见的原因还是“Session不共享”或者“Cookie的Path属性不对劲”。多实例部署时要么接spring-session-data-redis把Session集中存储,要么在负载均衡器上开启会话保持。
4.2 密码加密方式改变导致老用户无法登录
有个比较特殊的场景,老项目原来用MD5存密码,新项目切到BCrypt。迁移数据后,老用户全部登录失败,因为BCryptPasswordEncoder.matches()拿MD5密文去校验新输入的明文,不可能通过。
这时候DelegatingPasswordEncoder就派上用场了。它的登录流程是这样的:从用户表取出存储的密文,根据密文前缀判断原始加密方式,比如{MD5}前缀代表MD5、{bcrypt}前缀代表BCrypt,然后调用对应的PasswordEncoder去校验。同时upgradeEncoding()方法在登录成功后还能自动把老密文升级成新算法。
实现方式很简单:
@Bean public PasswordEncoder passwordEncoder() { String idForEncode = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put("bcrypt", new BCryptPasswordEncoder()); encoders.put("MD5", new MessageDigestPasswordEncoder("MD5")); return new DelegatingPasswordEncoder(idForEncode, encoders); }老数据迁移时,把密文统一加上前缀{MD5},新注册用户默认走BCrypt。登录时框架根据前缀自动选择编码器,登录成功后再把密文升级成BCrypt。这个方案我实际用到过,可以平滑解决历史密码迁移的问题。
4.3 静态资源不需要认证怎么配置
项目里CSS、JS、图片这些资源如果不放行,登录页就会变成纯HTML裸奔,样式全丢。放行静态资源的写法我已经在配置里给过了,这里再单独拎出来强调一下:
.authorizeHttpRequests(auth -> auth .requestMatchers("/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll() )这里有一个细节要注意,requestMatchers的匹配规则是Ant风格,/css/**能匹配/css下的所有层级。但如果你把登录页也放在public目录下,还要再想想路径规划,我自己的习惯是静态资源集中在/static目录,按类型分子目录,避免和业务路径混淆。
还有一种隐蔽情况:如果项目中用了WebMvcConfigurer自定义了资源映射,比如registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/static/assets/"),别忘了一起加入白名单。
4.4 几个让人印象深刻的事件监听
Spring Security发布了不少ApplicationEvent,我实际项目中使用比较多的是:
AuthenticationSuccessEvent:登录成功时触发。AuthenticationFailureBadCredentialsEvent:密码错误时触发。AuthenticationFailureLockedEvent:账号被锁定时触发。SessionCreationEvent、SessionDestroyedEvent:会话创建和销毁事件。
登录失败计数和账号锁定功能,我就是借助这些事件实现的,而不是在AuthenticationFailureHandler里硬写业务逻辑。事件驱动的好处是解耦,认证层只负责认证,业务层监听事件做自己的事。代码大概长这样:
@Component public class AuthenticationEventListener { private static final Map<String, Integer> FAIL_COUNT = new ConcurrentHashMap<>(); @EventListener public void handleBadCredentials(AuthenticationFailureBadCredentialsEvent event) { String username = event.getAuthentication().getName(); int count = FAIL_COUNT.merge(username, 1, Integer::sum); if (count >= 5) { // 触发账号锁定逻辑,比如更新数据库锁定状态 } } }这里要考虑分布式环境下的并发扣减问题,ConcurrentHashMap只是单机演示方案,生产环境要用Redis的INCR和过期时间来做。
4.5 CSRF开启后Ajax请求被拒绝
服务端渲染项目开了CSRF防护后,Ajax请求经常被框架拦截,返回403。原因是Ajax的请求头或请求体中没携带CSRF Token。解决方案有两个方向:
第一种,把CSRF Token放在Meta标签里,每次Ajax请求都从Meta读出来塞进请求头。
第二种,按官方规范,在每次Ajax请求前,从Cookie中读取CSRF Token,自动加进请求头。Spring Security支持把Token存到Cookie里,配置如下:
http.csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) )withHttpOnlyFalse是为了让前端JavaScript能读取到Cookie中的Token。这里要留意一下XSS风险:HttpOnly设为false后,一旦页面被注入脚本,攻击者可以直接读取Token。所以前端拿到Token后要尽快放进请求头,用完即忘,不要长时间留在JS作用域里。
如果项目是纯粹的API接口,不接受任何表单提交,也可以评估关闭CSRF,我做接口服务时一般就关掉,因为API用的是Token或签名认证,CSRF针对Cookie会话的攻击面已经不存在了。
4.6 常见问题速查表
这里我整理一份高频问题速查表,遇到问题可以快速对照排查。
| 问题现象 | 可能原因 | 处理方法 |
|---|---|---|
| 访问任意页面都跳到登录页 | permitAll没有配置静态资源和白名单路径 | 在requestMatchers中增加放行路径 |
| 登录页提交后返回403 | CSRF Token缺失 | 在表单中手动添加_csrf隐藏域 |
| 登录成功后页面刷新又回登录页 | 多实例Session不共享 | 接入spring-session-data-redis |
| 提示用户不存在 | UserDetailsService中loadUserByUsername返回了null或抛异常 | 确保用户存在时返回对象,不存在时抛出UsernameNotFoundException |
| 密码正确仍提示密码错误 | 数据库存了明文或用了不匹配的PasswordEncoder | 统一使用BCrypt,注册时用passwordEncoder.encode() |
访问/admin/**返回403 | 当前用户没有ROLE_ADMIN权限 | 给用户分配对应权限,或调整hasAuthority规则 |
| 某种依赖下启动报错提示循环依赖 | 配置类和UserDetailsService互相注入 | 把PasswordEncoder单独抽出Bean,避免在构造函数中互相依赖 |
| 记住我失效 | 表单缺少remember-me参数 | 增加checkbox,确保name为remember-me |
5. 更深一层:登录认证的扩展思路
5.1 从Session认证平滑过渡到Token认证
前面讲到的方案全部基于Session会话。如果项目要做前后端分离,比如前端用Vue或React,后端只提供接口,那这套方案就需要调整。Token方案里我一般从JWT切入。
JWT的逻辑是所有用户状态都编码在Token里,后端不存Session,接口通过Token确认身份。在Spring Security里切换到JWT的核心改动是:
- 添加
spring-security-jwt依赖。 - 自定义一个过滤器
JwtAuthenticationFilter,继承OncePerRequestFilter,从请求头Authorization: Bearer <token>中解析Token。 - Token解析成功后,把
Authentication对象塞进SecurityContextHolder。 SecurityFilterChain里用addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)把自定义过滤器插到认证过滤器之前。
这个方案适合移动端对接、跨域共享、无状态API场景。代价是Token无法主动失效,除非额外引入黑名单机制。如果项目没有跨域、移动端等硬需求,Session方案其实更省心。
5.2 认证与授权彻底分离的设计
当项目模块越来越复杂时,可以考虑把认证和授权彻底分离。认证中心只负责验证身份并发放凭证,各业务系统只做凭证校验和授权判断。这在Spring Security体系里就是引入AuthorizationManager定制授权策略,或者直接引入成熟的权限模型,比如RBAC。
RBAC是我用得最多的权限模型。用户归属角色,角色绑定权限,Spring Security的GrantedAuthority天然支持这个模型。用户表、角色表、用户角色关联表、权限表、角色权限关联表,五张表一搭,权限系统就骨架完整了。
5.3 登录流程中加入验证码
暴力破解防御除了失败次数锁定,另一个更常用的是登录验证码。在Spring Security体系里插入验证码校验可以放在UsernamePasswordAuthenticationFilter之前加一个自定义过滤器。过滤器的逻辑很简单:
- 取出用户填写的验证码和Session中保存的验证码。
- 比对一致才放行到后续的认证过滤器。
- 比对失败直接返回错误信息。
这样做的好处是验证码逻辑不会侵入Spring Security的核心认证流程,坏处是如果多个登录入口都要验证码,这个过滤器要重复复用。也可以把它封装成独立的HandlerInterceptor,只作用在登录提交的URL上。
5.4 自己动手做一个安全配置的“体检”
项目上线前,我习惯把安全配置重新检查一遍。这里分享几个自检项:
- 是否还有接口裸奔在
permitAll下,而这些接口并不该公开。 - 密码编码器是否统一,有没有明文密码漏网。
- 会话超时时间是否合理,是不是设置得过短影响体验。
- 是否开启了
sessionFixation().migrateSession(),防止会话固定攻击。 - 记住我密钥是否真实分布在配置中心,而非写死在代码中。
- 后端接口是否暴露了敏感信息,比如
/actuator端点是否加了保护。
这份检查清单覆盖了最容易出问题的几个方向。说句实在话,安全配置不是写完就完事的,每次升级Spring Boot版本后,我对齐一遍官方Release Notes,看看安全模块有没有行为变化。曾经一次从5.6升到5.7,默认的authorizeRequests就全面换成了authorizeHttpRequests,项目里所有安全配置全部要调整,这些升级细节网上教程不会主动提,只能自己踩或者靠官方文档慢慢对。
写在最后的实际操作体会
做完整套登录认证接入之后,我最大的体会是Spring Security像一个严谨得有些啰嗦的管家。它默认把所有资源都锁起来,逼着你一条条配置白名单;它默认开启CSRF,逼着你处理Token传递;它把密码存储的默认策略定成BCrypt,从源头上杜绝了裸奔的明文密码。这些默认行为看似麻烦,但其实都是在为安全兜底。
我在初学阶段也犯过很多次偷懒的错误,比如把所有接口一股脑permitAll,结果权限形同虚设;比如为了省事用NoOpPasswordEncoder,密码明文存储;比如关闭CSRF后没想清楚影响范围,直接导致登录接口可以被跨站请求伪造。这些坑踩过几回之后,我反而越用越小心,越用越觉得Spring Security的默认设计是有道理的。
对于刚上手的读者,我的建议只有一条:别急着关闭默认配置,先把默认行为跑通,再逐项覆盖。等你把过滤器链、认证流程、授权规则这些概念串起来之后,你就会明白,Spring Security不是让你多写了一堆配置,而是把安全这件事最难的脏活累活替你干完了;你要做的,只是告诉它在你的项目里“谁可以进来、进来后能干什么”罢了。