1. 这不是“加个依赖就能跑”的登录注册——Spring Security 的真实战场很多人第一次接触 Spring Security是在某篇教程里看到三行配置加一个spring-boot-starter-security依赖写个Configuration类继承WebSecurityConfigurerAdapter或用新式SecurityFilterChain再配个inMemoryUserDetailsManager。一运行果然——访问/login自动跳转输错密码提示“Bad credentials”输入对了就进首页。于是拍手“成了Spring Security 就是这么简单。”我试过三次这样的“快速上手”。第一次在模拟项目X的管理后台上线三天后被测试同学揪出用户注册时邮箱没校验格式密码明文存进数据库重置密码链接没有过期时间登录成功后 Session ID 被前端硬编码进 URL 里传播……第二次是在某高校课程设计系统中用默认的BCryptPasswordEncoder加密但忘了在用户注册时调用.encode()导致所有新用户密码实际是明文存储第三次更隐蔽——用了 JWT 做无状态认证却把 Token 存在 localStorage 里又没做 CSRF 防护结果 XSS 漏洞一触发Token 就被偷走。这些都不是“功能没实现”而是安全逻辑的结构性缺失。Spring Security 不是一个“开关式”框架它是一套可插拔、可编排、有明确责任边界的安全策略执行引擎。它的核心价值从来不是帮你生成一个登录页而是让你能清晰回答五个问题用户身份从哪里来认证源内存 / JDBC / LDAP / OAuth2 / 自定义身份凭证如何验证密码编码器选型、多因素校验时机、失败次数限制认证成功后用户能做什么授权决策URL 级 / 方法级 / 数据级敏感操作是否被额外约束CSRF、CORS、点击劫持、内容安全策略凭证生命周期如何管控Session 超时、Token 刷新、登出清理、并发登录踢出这五个问题每一个都对应着 Spring Security 中一个可独立配置、可深度定制的模块。而“登录注册”这个看似简单的功能组合恰恰是串联起认证Authentication与用户管理UserDetailsService两大主干的典型入口。它表面是表单提交底层却是过滤器链Filter Chain中UsernamePasswordAuthenticationFilter与DaoAuthenticationProvider的协作是UserDetails对象从数据库加载、密码比对、权限封装的完整闭环。所以本文不讲“怎么让登录页面出来”而是带你亲手拆开这个闭环从最基础的表单认证开始逐步叠加密码强度校验、邮箱激活、图形验证码、登录失败锁定、注册防机器人等真实生产环境必须面对的环节。每一步我都说明“为什么必须加”“不加会怎样”“加在哪一层最合理”并给出可直接复用的代码结构和配置要点。这不是 Demo这是你在模拟项目X中真正要交付的安全基线。提示本文所有代码基于 Spring Boot 3.2 Spring Security 6.2即SecurityFilterChain新范式不兼容已废弃的WebSecurityConfigurerAdapter。如果你还在用旧版建议先完成迁移——这不是升级而是安全模型的根本性演进。2. 从零构建可落地的认证流程不是写死密码而是建模用户生命周期很多初学者卡在第一步连登录表单都搭不起来。不是不会写 HTML而是不知道 Spring Security 的“约定”在哪里生效。它不像传统 MVC 那样靠Controller显式映射/login而是通过SecurityFilterChain的声明式配置在请求进入业务逻辑前由一系列预置过滤器自动拦截、解析、响应。2.1 安全过滤器链的起点理解SecurityFilterChain的职责边界SecurityFilterChain是 Spring Security 6 的核心配置载体。它不是一个“配置类”而是一个被 Spring 容器管理的 Bean其作用是告诉 Security对哪些请求路径启用安全策略以及启用哪些具体的安全机制。我们先看一个最简但完整的配置Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz - authz .requestMatchers(/login, /register, /css/**, /js/**, /images/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).authenticated() .anyRequest().denyAll() ) .formLogin(form - form .loginPage(/login) .loginProcessingUrl(/login/do) .defaultSuccessUrl(/user/dashboard, true) .failureUrl(/login?errortrue) .usernameParameter(username) .passwordParameter(password) ) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(/login?logouttrue) ); return http.build(); }这段代码定义了三条关键规则放行规则permitAll/login、/register和静态资源路径不经过认证检查。注意/login是登录页面的 GET 请求路径而/login/do是表单 POST 提交的目标后者必须不在permitAll列表中否则认证逻辑会被跳过。角色授权hasRole访问/admin/**下任何资源必须拥有ROLE_ADMIN权限注意 Spring Security 会自动为hasRole(ADMIN)添加ROLE_前缀。登录流程绑定formLoginloginPage(/login)当未认证用户访问受保护资源时自动重定向到此路径loginProcessingUrl(/login/do)表单form action/login/do的提交目标这是UsernamePasswordAuthenticationFilter实际监听的 URLdefaultSuccessUrl(..., true)true表示“总是跳转到该 URL”而非“返回用户最初请求的页面”这对管理后台很实用failureUrl认证失败后重定向的地址通常在此页面显示错误提示。这个配置本身不处理“用户从哪来”“密码怎么验”它只负责“路由”——把/login/do的请求交给内置的认证过滤器把/register的请求放行给你的Controller。2.2 用户数据建模UserDetails与UserDetailsService的契约关系Spring Security 不关心你用 MySQL、MongoDB 还是 Redis 存用户。它只认一个接口UserDetailsService。你必须提供一个实现类告诉 Security“当用户名xxx被传进来时你去数据库查给我一个UserDetails对象”。UserDetails是一个数据载体接口它强制你提供getUsername()登录用的唯一标识通常是用户名或邮箱getPassword()加密后的密码字符串如$2a$10$...不是明文getAuthorities()用户拥有的权限列表GrantedAuthority决定能访问哪些资源isEnabled()、isAccountNonExpired()、isCredentialsNonExpired()、isAccountNonLocked()四个布尔方法用于账户状态控制禁用、过期、密码过期、锁定。我们定义一个实体类AppUser它对应数据库表Entity Table(name app_user) public class AppUser { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private String email; private String password; // 已加密 private boolean enabled true; private boolean accountNonLocked true; private LocalDateTime lastLoginTime; private int loginFailureCount 0; // 登录失败次数用于锁定 private LocalDateTime lockUntil; // 锁定截止时间 }然后实现UserDetailsServiceService public class JpaUserDetailsService implements UserDetailsService { private final UserRepository userRepository; public JpaUserDetailsService(UserRepository userRepository) { this.userRepository userRepository; } Override public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException { // 允许用户名或邮箱登录 AppUser user userRepository.findByUsername(usernameOrEmail) .orElseGet(() - userRepository.findByEmail(usernameOrEmail) .orElseThrow(() - new UsernameNotFoundException(User not found: usernameOrEmail)); // 检查账户状态 if (!user.isEnabled()) { throw new DisabledException(Account is disabled); } if (!user.isAccountNonLocked()) { if (user.getLockUntil() ! null user.getLockUntil().isAfter(LocalDateTime.now())) { throw new LockedException(Account is locked until user.getLockUntil()); } else { // 锁定已过期重置计数器 user.setLoginFailureCount(0); user.setAccountNonLocked(true); user.setLockUntil(null); userRepository.save(user); } } // 封装为 UserDetails return User.builder() .username(user.getUsername()) .password(user.getPassword()) .authorities(getAuthorities(user)) .accountExpired(false) .accountLocked(!user.isAccountNonLocked()) .credentialsExpired(false) .disabled(!user.isEnabled()) .build(); } private Collection? extends GrantedAuthority getAuthorities(AppUser user) { // 简单示例从数据库查用户角色这里硬编码 ADMIN 和 USER ListSimpleGrantedAuthority authorities new ArrayList(); if (admin.equals(user.getUsername())) { authorities.add(new SimpleGrantedAuthority(ROLE_ADMIN)); } else { authorities.add(new SimpleGrantedAuthority(ROLE_USER)); } return authorities; } }这段代码的关键点在于loadUserByUsername方法名是契约Spring Security 在UsernamePasswordAuthenticationFilter中就是调用这个方法获取用户数据。你不能改名也不能让它返回null必须抛UsernameNotFoundException。状态检查前置在构造UserDetails之前就检查enabled、locked状态并抛出对应异常。Spring Security 会捕获这些异常并在登录页面显示相应提示需配合AuthenticationFailureHandler自定义。密码字段是加密后字符串user.getPassword()返回的是类似$2a$10$abc123...的 BCrypt 哈希值不是明文。这是DaoAuthenticationProvider进行密码比对的前提。2.3 密码编码器为什么BCryptPasswordEncoder是默认但不是万能BCryptPasswordEncoder是 Spring Security 的默认密码编码器原因很实在自包含哈希哈希值中自带盐salt和轮数cost factor无需单独存盐字段计算耗时可控cost10时单次哈希约需 100ms极大增加暴力破解成本抗彩虹表每次哈希结果不同即使相同密码也生成不同哈希。但它不是银弹。比如你接手一个老系统用户密码是用 MD5 加盐存的你不能直接换 BCrypt——因为老用户无法登录。此时你需要多编码器支持Bean public PasswordEncoder passwordEncoder() { MapString, PasswordEncoder encoders new HashMap(); encoders.put(bcrypt, new BCryptPasswordEncoder()); encoders.put(md5, new Md5PasswordEncoder()); // 自定义实现仅作示例 DelegatingPasswordEncoder delegatingEncoder new DelegatingPasswordEncoder(bcrypt, encoders); delegatingEncoder.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder()); return delegatingEncoder; }这样数据库中密码字段可以带前缀{bcrypt}$2a$10$...或{md5}xxxSpring Security 会根据前缀自动选择解码器。新注册用户一律用 BCrypt老用户登录成功后再用新编码器重写密码。注意MD5 已不安全此处仅为说明多编码器场景。生产环境请勿使用 MD5、SHA1 等弱哈希算法。3. 注册流程的深度加固从“能注册”到“安全地注册”登录只是半程注册才是安全防线的第一道闸口。一个开放的注册接口如果缺乏防护会迅速成为垃圾账号、撞库攻击、自动化注册机器人的温床。Spring Security 本身不提供注册逻辑但它的生态组件和标准实践能帮你构建一道坚固的注册墙。3.1 图形验证码不是加个图片而是阻断自动化脚本的“感知层”验证码CAPTCHA的核心目的是区分人类与机器。它不是为了“难住人”而是让 OCR 和自动化脚本失效。常见的“数字字母”图片验证码其技术本质是服务端生成随机字符串如K7mP9将字符串渲染为扭曲、加噪、干扰线的图片将原始字符串存入 Session 或 Redis带 TTL客户端提交表单时同时提交图片中的文本和用户输入服务端比对用户输入与 Session 中存储的原始字符串。关键陷阱在于验证码校验必须在 Spring Security 的认证流程之外完成。因为UsernamePasswordAuthenticationFilter只管用户名密码不处理验证码字段。你必须在自己的注册 Controller 中先校验验证码再调用UserDetailsService创建用户。我们用kaptcha库轻量、易集成实现!-- pom.xml -- dependency groupIdcom.github.penggle/groupId artifactIdkaptcha/artifactId version2.3.2/version /dependency配置 Kaptcha BeanConfiguration public class KaptchaConfig { Bean public DefaultKaptcha producer() { Properties properties new Properties(); properties.setProperty(kaptcha.image.width, 120); properties.setProperty(kaptcha.image.height, 40); properties.setProperty(kaptcha.textproducer.char.length, 4); properties.setProperty(kaptcha.textproducer.font.color, black); properties.setProperty(kaptcha.noise.impl, com.google.code.kaptcha.impl.NoNoise); Config config new Config(properties); DefaultKaptcha defaultKaptcha new DefaultKaptcha(); defaultKaptcha.setConfig(config); return defaultKaptcha; } }Controller 中生成和校验Controller public class AuthController { Autowired private DefaultKaptcha defaultKaptcha; Autowired private UserRepository userRepository; GetMapping(/captcha.jpg) public void captcha(HttpServletRequest request, HttpServletResponse response) throws IOException { response.setDateHeader(Expires, 0); response.setHeader(Cache-Control, no-store, no-cache, must-revalidate, max-age0); response.setHeader(Pragma, no-cache); response.setContentType(image/jpeg); String capText defaultKaptcha.createText(); // 生成随机字符串 BufferedImage image defaultKaptcha.createImage(capText); // 渲染图片 // 存入 Session有效期 5 分钟 request.getSession().setAttribute(captcha, capText); ServletOutputStream out response.getOutputStream(); ImageIO.write(image, jpg, out); out.flush(); } PostMapping(/register) public String register(RequestParam String username, RequestParam String email, RequestParam String password, RequestParam String captchaInput, HttpServletRequest request, Model model) { // 1. 校验验证码 String captchaInSession (String) request.getSession().getAttribute(captcha); if (captchaInSession null || !captchaInSession.equalsIgnoreCase(captchaInput)) { model.addAttribute(error, 验证码错误); return register; // 返回注册页 } // 2. 校验邮箱格式基础 if (!email.matches(^[A-Za-z0-9_.-][A-Za-z0-9.-]\\.[A-Za-z]{2,}$)) { model.addAttribute(error, 邮箱格式不正确); return register; } // 3. 检查用户名/邮箱是否已存在 if (userRepository.findByUsername(username).isPresent() || userRepository.findByEmail(email).isPresent()) { model.addAttribute(error, 用户名或邮箱已被注册); return register; } // 4. 创建用户密码加密 AppUser user new AppUser(); user.setUsername(username); user.setEmail(email); user.setPassword(passwordEncoder().encode(password)); // 使用全局 PasswordEncoder user.setEnabled(false); // 先禁用待邮箱激活 userRepository.save(user); // 5. 发送激活邮件异步 emailService.sendActivationEmail(user.getEmail(), user.getId()); model.addAttribute(message, 注册成功请查收邮箱激活链接); return redirect:/login; } }这里的关键设计是验证码存储在 Session 中简单可靠适合单机部署。若集群部署需改用 Redis 存储并设置 Key 前缀隔离。校验在 Controller 层完成不侵入 Security Filter Chain职责清晰。注册后用户enabledfalse强制邮箱激活避免无效账号堆积。3.2 邮箱激活用一次性 Token 实现“双向确认”邮箱激活的本质是验证“用户确实拥有该邮箱地址”。最安全的方式是生成一个有时效、不可预测、一次性的激活 Token将其与用户 ID 绑定发送到邮箱用户点击链接后服务端验证 Token 有效性并激活账户。我们不用 UUID而用SecureRandom生成 32 字节随机 Token并 Base64 编码Service public class ActivationService { private final UserRepository userRepository; private final RedisTemplateString, Object redisTemplate; public ActivationService(UserRepository userRepository, RedisTemplateString, Object redisTemplate) { this.userRepository userRepository; this.redisTemplate redisTemplate; } public String generateActivationToken(Long userId) { SecureRandom random new SecureRandom(); byte[] tokenBytes new byte[32]; random.nextBytes(tokenBytes); String token Base64.getEncoder().encodeToString(tokenBytes); // 存入 Redis有效期 24 小时 redisTemplate.opsForValue().set(activation: token, userId, Duration.ofHours(24)); return token; } public boolean activateAccount(String token) { Object userIdObj redisTemplate.opsForValue().get(activation: token); if (userIdObj null) { return false; } Long userId ((Long) userIdObj); OptionalAppUser userOpt userRepository.findById(userId); if (userOpt.isPresent()) { AppUser user userOpt.get(); user.setEnabled(true); userRepository.save(user); // 激活成功删除 Token redisTemplate.delete(activation: token); return true; } return false; } }Controller 处理激活请求GetMapping(/activate) public String activate(RequestParam String token, Model model) { if (activationService.activateAccount(token)) { model.addAttribute(message, 激活成功现在可以登录了); } else { model.addAttribute(error, 激活链接无效或已过期); } return activate-result; }提示生产环境务必使用 HTTPS 发送激活邮件且邮件中的链接应包含https://前缀。HTTP 链接在现代浏览器中可能被拦截。3.3 防机器人注册RateLimiting 与行为分析的组合拳验证码能挡住简单脚本但挡不住高级爬虫。真正的防护需要速率限制Rate Limiting和行为特征分析。Rate Limiting对/register接口限制同一 IP 地址 1 小时内最多请求 5 次。超过则返回429 Too Many Requests。行为分析检查请求头User-Agent是否为空或为已知爬虫如python-requests检查Referer是否来自你的注册页检查请求间隔是否过短如 100ms 内连续提交。我们用 Spring Boot 的RestControllerAdviceRedis实现简易限流RestControllerAdvice public class RateLimitAdvice { Autowired private RedisTemplateString, Object redisTemplate; ExceptionHandler(RateLimitExceededException.class) public ResponseEntityMapString, String handleRateLimit( RateLimitExceededException ex) { MapString, String error new HashMap(); error.put(error, 请求过于频繁请稍后再试); return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(error); } } // 自定义注解 Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { String key() default ; // 限流 Key 前缀 int limit() default 5; // 限制次数 int periodSeconds() default 3600; // 时间窗口秒 } // AOP 切面 Aspect Component public class RateLimitAspect { Autowired private RedisTemplateString, Object redisTemplate; Around(annotation(rateLimit)) public Object checkRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String ip getClientIpAddress((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()); String key rateLimit.key() : ip; Long count redisTemplate.opsForValue().increment(key, 1); if (count 1) { redisTemplate.expire(key, Duration.ofSeconds(rateLimit.periodSeconds())); } if (count rateLimit.limit()) { throw new RateLimitExceededException(); } return joinPoint.proceed(); } private String getClientIpAddress(ServletRequestAttributes attributes) { HttpServletRequest request attributes.getRequest(); String xForwardedFor request.getHeader(X-Forwarded-For); if (xForwardedFor ! null !xForwardedFor.isEmpty() !unknown.equalsIgnoreCase(xForwardedFor)) { return xForwardedFor.split(,)[0].trim(); } return request.getRemoteAddr(); } }然后在注册方法上添加注解PostMapping(/register) RateLimit(key register, limit 5, periodSeconds 3600) public String register(...) { ... }这套组合验证码 邮箱激活 IP 限流 User-Agent 检查能有效抵御 95% 的自动化注册攻击。剩下的 5%需要 WAFWeb 应用防火墙和更精细的行为分析那已超出 Spring Security 的范畴。4. 登录环节的实战加固从“能登录”到“安全地登录”登录是认证的终点也是攻击的焦点。暴力破解、撞库、会话固定、CSRF……每一个都是真实存在的威胁。Spring Security 提供了开箱即用的防护但前提是你理解每个防护项的原理并正确启用它。4.1 登录失败锁定用状态机思维管理账户安全生命周期账户锁定Account Locking不是简单的“输错三次就锁”而是一个状态机管理过程UNLOCKED → (失败) → FAILED_ONCE → (失败) → FAILED_TWICE → (失败) → LOCKED → (超时) → UNLOCKED我们在AppUser实体中已定义了loginFailureCount和lockUntil字段。现在需要在认证失败时更新它们。Spring Security 提供AuthenticationFailureHandler接口用于处理认证失败后的逻辑Component public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { Autowired private UserRepository userRepository; Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { String username request.getParameter(username); if (username ! null) { OptionalAppUser userOpt userRepository.findByUsername(username); if (userOpt.isPresent()) { AppUser user userOpt.get(); int newCount user.getLoginFailureCount() 1; if (newCount 3) { // 锁定 30 分钟 user.setAccountNonLocked(false); user.setLockUntil(LocalDateTime.now().plusMinutes(30)); } user.setLoginFailureCount(newCount); userRepository.save(user); } } // 重定向回登录页并携带错误参数 response.sendRedirect(/login?errortrue); } }然后在SecurityFilterChain中注册Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // ... 其他配置 .formLogin(form - form .failureHandler(customAuthenticationFailureHandler()) // 替换默认 handler ); return http.build(); }这个设计的关键在于失败计数与锁定逻辑分离onAuthenticationFailure只负责更新状态真正的锁定检查在UserDetailsService.loadUserByUsername中完成见 2.2 节。这样即使用户在锁定期间尝试其他方式如 API 登录也会被统一拦截。锁定时间可配置plusMinutes(30)可改为从配置中心读取便于运维调整。4.2 防止会话固定Session Fixation为什么changeSessionId()是必须的会话固定攻击Session Fixation是指攻击者诱使用户使用一个已知的 Session ID 登录从而在用户登录后获得其认证后的会话权限。Spring Security 默认开启防护但你必须确保使用HttpSession而非自定义 Session 存储在SecurityFilterChain中显式启用sessionManagement并配置invalidSessionStrategy可选Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // ... 其他配置 .sessionManagement(session - session .invalidSessionUrl(/login?expiredtrue) // Session 失效后跳转 .sessionFixation(SessionCreationPolicy.ALWAYS) // 登录后总是创建新 Session ); return http.build(); }SessionCreationPolicy.ALWAYS的含义是无论当前是否存在 Session只要认证成功就销毁旧 Session创建一个全新的 Session ID。这是最严格的防护策略。注意如果你使用 JWT 无状态认证则不存在 Session 固定问题因为根本没有服务端 Session。但 JWT 有自己的一套安全要求如短期 Token、Refresh Token 保护等那是另一个话题。4.3 CSRF 防护不只是加个EnableWebMvcSecurity而是理解 Token 传递链CSRF跨站请求伪造攻击是利用用户已登录的合法身份诱导其在不知情下提交恶意请求如转账、删帖。Spring Security 默认启用 CSRF 防护其核心是所有状态变更请求POST/PUT/DELETE必须携带一个服务端签发的、与用户 Session 绑定的 Token。这个 Token 的传递链是服务端生成在用户首次访问/login页面时Spring Security 自动在 Session 中生成一个_csrfToken并将其注入到 Thymeleaf 模板的隐藏字段中form th:action{/login/do} methodpost input typehidden name_csrf th:value${_csrf.token} / !-- 其他字段 -- /form客户端提交表单提交时_csrf字段随请求一起发送。服务端校验CsrfFilter拦截请求从请求体中提取_csrf并与 Session 中存储的 Token 比对。不匹配则拒绝。你不需要手动管理这个 Token但必须确保所有表单都包含_csrf字段Thymeleaf 会自动注入但如果你用纯 HTML 或 Vue/React 前端则需通过/csrf接口或在登录成功响应头中获取 Token并在请求头X-CSRF-TOKEN或请求体中携带。AJAX 请求正确设置 Header例如 jQuery$.ajaxSetup({ beforeSend: function(xhr, settings) { if (settings.type POST || settings.type PUT || settings.type DELETE) { xhr.setRequestHeader(X-CSRF-TOKEN, $(meta[name_csrf]).attr(content)); } } });提示CSRF 防护对 GET 请求无效因为 GET 应该是幂等的所以/logout的 GET 请求是不安全的。Spring Security 3.2 已强制要求/logout必须是 POST这也是为什么我们在SecurityFilterChain中配置了.logoutUrl(/logout)—— 它实际监听的是 POST/logout。5. 权限模型的落地实践从“能访问”到“精准授权”登录注册解决了“你是谁”而授权Authorization解决的是“你能做什么”。Spring Security 提供了 URL 级、方法级、甚至数据级的授权能力。但在真实项目中权限模型往往比hasRole(ADMIN)复杂得多。5.1 基于角色的访问控制RBAC为什么ROLE_前缀是铁律Spring Security 的hasRole(ADMIN)实际上等价于hasAuthority(ROLE_ADMIN)。它会自动为传入的角色名添加ROLE_前缀。这是硬编码在RoleVoter中的逻辑。这意味着如果你在数据库中存的权限是ADMIN那么getAuthorities()方法必须返回ROLE_ADMIN而不是ADMIN。否则hasRole(ADMIN)永远为false。我们改进JpaUserDetailsService.getAuthorities()private Collection? extends GrantedAuthority getAuthorities(AppUser user) { ListSimpleGrantedAuthority authorities new ArrayList(); // 从数据库查用户角色假设有一张 user_role 关联表 ListString roleNames userRoleRepository.findRoleNamesByUserId(user.getId()); for (String roleName : roleNames) { authorities.add(new SimpleGrantedAuthority(ROLE_ roleName.toUpperCase())); } // 如果用户是超级管理员额外赋予所有权限谨慎使用 if (user.getUsername().equals(superadmin)) { authorities.add(new SimpleGrantedAuthority(ROLE_SUPERADMIN)); } return authorities; }这样数据库只需存admin、user、editor等角色名Java 层统一加上ROLE_前缀保持语义清晰。5.2 方法级安全Method Security在 Service 层做细粒度控制URL 级授权authorizeHttpRequests只能控制“谁能访问哪个接口”但无法控制“同一个接口内不同用户能操作哪些数据”。例如用户 A 能访问/api/posts/123但不能编辑别人发布的文章。这时就需要PreAuthorize/PostAuthorize注解在 Service 方法执行前/后进行权限判断Service public class PostService { PreAuthorize(hasRole(ADMIN) or #postId principal.id) public Post updatePost(Long postId, String title, String content) { // 只有 ADMIN 角色或 postId 对应的作者principal.id 是当前用户 ID才能执行 return postRepository.findById(postId) .map(post - { post.setTitle(title); post.setContent(content); return postRepository.save(post); }) .orElseThrow(() - new PostNotFoundException(postId)); } }要启用方法级安全必须在配置类上加EnableMethodSecurityConfiguration EnableMethodSecurity public class MethodSecurityConfig { // 无需其他配置默认启用 PreAuthorize 等注解 }principal.id是 Spring Security 的 SpELSpring Expression Language表达式它代表当前认证用户的UserDetails对象。你可以访问principal.username、principal.authorities等任意属性。5.3 数据级权限Row-Level Security用PostFilter过滤查询结果更进一步当一个用户查询“我的所有文章”时后端不应返回所有文章再由前端过滤而应在数据库查询层面就加上WHERE author_id ?条件。PostFilter可以在方法返回集合后根据表达式过滤元素Repository public interface PostRepository extends JpaRepositoryPost, Long { ListPost findByAuthorId(Long authorId); } Service public class PostService { PostFilter(filterObject.author.id principal.id or hasRole(ADMIN)) public ListPost findAllPosts() { return postRepository.findAll(); } }filterObject是集合中的每个元素即Post对象filterObject.author.id就是该文章作者的 ID。只有当作者 ID 等于当前用户 ID或当前用户是 ADMIN 时该文章才会保留在返回列表中。注意PostFilter是在内存中过滤对大数据集性能不佳。生产环境更推荐在 Repository 层直接写带条件的查询方法如findByAuthorId或使用数据库行级安全策略如 PostgreSQL 的 RLS。6. 生产环境必做的五项加固超越教程的实战清单以上所有内容构成了一个可运行的、相对安全的登录注册系统。但要真正上线还有五项关键加固措施它们往往被教程忽略却是运维和安全部门最关注的点。6.1 密码强度策略不是“8位以上”而是可配置、可审计的规则引擎强制用户设置“8位以上、大小写字母数字符号”的密码是基本要求。但更专业的要求是可配置密码策略最小长度、必须包含的字符类型、禁止的常见密码应从配置文件或数据库读取而非硬编码可审计记录用户修改密码的操作日志谁、何时、从什么 IP防撞库检查新密码是否出现在公开的泄露密码库中如 Have I Been Pwned API。我们用一个简单的策略类实现Component public class PasswordPolicyValidator { private final int minLength 10; private final boolean requireUppercase true; private final boolean requireLowercase true; private final boolean requireDigit true; private final boolean requireSpecialChar true; public ValidationResult validate(String password) { ValidationResult result new ValidationResult(); if (password.length() minLength) { result.addError(密码长度不能少于 minLength 位); } if (requireUppercase
阅读完成 · 觉得有帮助?