1. 从零到一:快速搭建你的第一个安全防护
如果你刚开始接触Spring Security,可能会觉得它有点复杂,配置项多得让人眼花缭乱。别担心,我刚开始用的时候也是这种感觉,总觉得这玩意儿是不是有点“杀鸡用牛刀”了。但用久了才发现,它把很多复杂的安全问题都封装好了,我们只需要按需配置就行。今天我就从一个最简单的例子开始,带你一步步搭建一个具备基础认证授权的Web应用。
首先,我们得创建一个Spring Boot项目。现在用IDEA或者Spring Initializr都很方便,记得勾选上 Spring Web 和 Spring Security 这两个依赖。项目创建好后,你会发现什么都不用做,直接启动,访问任何一个接口,浏览器都会弹出一个登录框。这就是Spring Security的默认行为——它已经为你开启了一个最基本的安全防护,所有请求都需要认证,并且提供了一个默认用户(用户名是 user,密码会在控制台打印出来)。
这个默认行为虽然方便,但离我们实际项目需求差得远。我们通常需要自定义用户、定义哪些页面需要登录、哪些可以公开访问。这就得靠我们自己的配置类了。创建一个 SecurityConfig 类,继承 WebSecurityConfigurerAdapter(注意:在Spring Security 5.7+版本,官方推荐使用基于组件的配置,但为了理解原理,我们先从经典的适配器模式开始)。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home", "/css/**", "/js/**").permitAll() // 允许所有人访问的路径
.antMatchers("/user/**").hasRole("USER") // 需要USER角色
.antMatchers("/admin/**").hasRole("ADMIN") // 需要ADMIN角色
.anyRequest().authenticated() // 其他所有请求都需要认证
.and()
.formLogin()
.loginPage("/login") // 自定义登录页路径
.permitAll() // 登录页本身需要允许所有人访问
.and()
.logout()
.permitAll(); // 允许所有人登出
}
@Bean
@Override
public UserDetailsService userDetailsService() {
// 先在内存中创建两个用户,方便测试
UserDetails user = User.withDefaultPasswordEncoder()
.username("zhangsan")
.password("123456")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("lisi")
.password("admin123")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}
这段代码干了这么几件事:首先,我们通过 authorizeRequests() 开始定义访问规则。antMatchers() 用来匹配URL路径,permitAll() 表示完全放行,hasRole() 表示需要特定角色。这里我特意把静态资源(如CSS、JS)也放行了,不然你的登录页可能没有样式。然后,我们配置了表单登录,指定了登录页的路径是 /login,并且这个页面本身也要允许未登录用户访问,否则就死循环了。最后,配置了登出功能。
这里有个踩过的坑:User.withDefaultPasswordEncoder() 这个方法在开发测试时很方便,但它用的是不安全的 PlaintextPasswordEncoder,绝对不要在生产环境使用!我们只是为了快速演示。生产环境的密码加密我们后面会详细讲。
启动项目,访问 /admin/dashboard,你会被重定向到 /login 页面(如果你自己没写这个页面,Spring Security会提供一个非常简陋的默认页面)。用 lisi/admin123 登录,就能成功访问了。如果用 zhangsan/123456 登录,则会看到403禁止访问的错误页,因为张三没有ADMIN角色。你看,基本的角色访问控制(RBAC)就这么搭起来了,是不是比想象中简单?
2. 深入认证核心:自定义你的用户来源
刚才的例子我们把用户信息硬编码在内存里,这显然不现实。真实项目中,用户数据肯定存在数据库里。Spring Security通过 UserDetailsService 这个接口来加载用户信息,我们只需要实现它,从数据库查出用户,然后组装成 UserDetails 对象返回就行。
假设我们有一个简单的用户表,有用户名、密码和角色字段。首先,我们得创建对应的实体和Repository(这里用JPA举例)。
@Entity
public class SysUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private String role; // 简单起见,一个用户一个角色,如 "ROLE_ADMIN"
// 省略 getter/setter
}
public interface UserRepository extends JpaRepository<SysUser, Long> {
SysUser findByUsername(String username);
}
接下来,就是实现核心的 UserDetailsService。
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 根据用户名从数据库查询用户
SysUser sysUser = userRepository.findByUsername(username);
if (sysUser == null) {
throw new UsernameNotFoundException("用户不存在: " + username);
}
// 2. 将数据库中的角色字符串转换为Spring Security需要的GrantedAuthority集合
// 注意:如果使用hasRole,这里角色名需要带`ROLE_`前缀。如果数据库里存的是`ADMIN`,这里要加上。
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(sysUser.getRole()));
// 3. 构建并返回UserDetails对象
return new User(
sysUser.getUsername(),
sysUser.getPassword(), // 数据库里存的应该是加密后的密码
authorities
);
}
}
实现完成后,我们需要在配置类里告诉Spring Security使用我们自定义的 UserDetailsService,并且配置一个密码编码器(PasswordEncoder)来校验密码。修改之前的 SecurityConfig:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
// 使用BCrypt强哈希算法,这是目前最推荐的方式
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService) // 设置自定义的UserDetailsService
.passwordEncoder(passwordEncoder()); // 设置密码编码器
}
// ... 其他的HttpSecurity配置保持不变
}
这里有几个关键点和我踩过的坑:
- 密码编码器必须匹配:你数据库里存的密码,必须是使用同一个
PasswordEncoder加密后的结果。比如你注册用户时用BCryptPasswordEncoder.encode("rawPassword")加密密码再存库,这里校验时才能对上。如果对不上,登录就会失败。 - 角色前缀:注意我在
CustomUserDetailsService里的注释。hasRole("ADMIN")在检查时,实际上会去查找名为ROLE_ADMIN的权限。所以,要么你在数据库里就存ROLE_ADMIN,要么在组装GrantedAuthority时手动加上ROLE_前缀。而hasAuthority()则直接检查权限字符串,不需要加前缀。这个区别很容易搞混,导致配置了角色却总没权限。 - AuthenticationManagerBuilder:这里是配置认证管理的地方。我们通过
userDetailsService()指定用户来源,通过passwordEncoder()指定密码校验器。这样,当用户登录时,Spring Security就会自动调用我们的CustomUserDetailsService加载用户,并用BCryptPasswordEncoder来比对前端传来的密码和数据库存储的哈希值。
3. 玩转授权:方法级与URL级权限控制
权限控制分两个层面:一个是Web请求层面(URL),另一个是方法调用层面。我们之前用 .antMatchers().hasRole() 就是在做URL级别的控制。这很直观,适合保护整个页面或API接口。但有时候,我们需要更细粒度的控制,比如同一个 UserController 的 getUserInfo 方法所有登录用户都能访问,而 deleteUser 方法只有管理员才能调用。这时候就需要方法级安全控制。
首先,要在配置类上加上 @EnableGlobalMethodSecurity 注解来启用它。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// ... 其他配置
}
prePostEnabled = true:启用Spring EL表达式注解,如@PreAuthorize。securedEnabled = true:启用@Secured注解。
技巧一:使用 @Secured 注解
这个注解比较简单,直接指定角色名。
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/info")
@Secured("ROLE_USER") // 需要USER角色
public String getUserInfo() {
return "用户信息";
}
@DeleteMapping("/{id}")
@Secured("ROLE_ADMIN") // 需要ADMIN角色
public String deleteUser(@PathVariable Long id) {
return "删除用户成功";
}
}
技巧二:使用更强大的 @PreAuthorize 注解
这个注解支持Spring EL表达式,功能强大得多,可以实现复杂的逻辑判断。
@Service
public class OrderService {
// 只有管理员,或者订单的所有者本人才能查看订单详情
@PreAuthorize("hasRole('ADMIN') or #order.user.username == authentication.name")
public Order getOrderDetail(Order order) {
// ... 业务逻辑
}
// 检查用户是否有某个特定的权限(而非角色)
@PreAuthorize("hasAuthority('ORDER_CREATE')")
public Order createOrder(Order order) {
// ... 业务逻辑
}
}
看到没?在表达式里,authentication.name 可以直接拿到当前登录用户的用户名,#order 可以引用方法的参数。这让我们可以实现基于“数据”的权限控制,非常灵活。
技巧三:结合URL配置,实现双重防护
在实际项目中,我通常会将两者结合。在 SecurityConfig 中配置粗粒度的URL规则作为第一道防线,然后在关键的业务方法上使用 @PreAuthorize 做细粒度的校验。这样即使有人绕过了前端页面直接调用API,我们的方法级安全依然能起作用。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/user/**").hasAnyRole("USER", "ADMIN") // URL层:用户相关API需要登录
.antMatchers("/api/admin/**").hasRole("ADMIN") // URL层:管理后台API需要管理员
.anyRequest().authenticated();
// ... 其他配置
}
然后在 UserController 的删除方法上加 @PreAuthorize("hasRole('ADMIN')")。这样,即使一个普通USER用户通过某种手段构造了删除请求,也会在进入方法前被拦截。
4. 无状态API的守护神:JWT集成实战
现在前后端分离是主流,API通常是无状态(Stateless)的,传统的Session-Cookie机制就不太合适了。JWT(JSON Web Token)成了最流行的解决方案。Spring Security集成JWT,核心思路就是自定义一个过滤器(Filter),放在认证链条里,让它来解析请求头中的Token,并设置安全上下文。
第一步:引入依赖和工具类
首先在 pom.xml 中加入JWT库的依赖,比如Java JWT (jjwt)。
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
然后写一个JWT工具类,负责生成、解析、验证Token。
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpirationInMs;
// 生成Token
public String generateToken(Authentication authentication) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(authentication.getName())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
// 从Token中获取用户名
public String getUsernameFromJWT(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(jwtSecret)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
// 验证Token
public boolean validateToken(String authToken) {
try {
Jwts.parserBuilder().setSigningKey(jwtSecret).build().parseClaimsJws(authToken);
return true;
} catch (Exception ex) {
// 可以记录日志,Token过期、格式错误等
}
return false;
}
}
第二步:创建JWT认证过滤器
这是最核心的一步。这个过滤器会检查每个请求的 Authorization 头,如果有合法的JWT,就手动创建一个 Authentication 对象并放到 SecurityContextHolder 里。
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
// 1. 从请求头中获取JWT
String jwt = getJwtFromRequest(request);
// 2. 验证JWT的有效性
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
// 3. 从JWT中解析出用户名
String username = tokenProvider.getUsernameFromJWT(jwt);
// 4. 根据用户名加载用户信息,生成Authentication对象
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 5. 将Authentication设置到安全上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("无法在请求中设置用户认证", ex);
}
// 继续执行过滤器链
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
第三步:修改安全配置,接入过滤器
我们需要修改 SecurityConfig,告诉Spring Security使用无状态会话,并把我们的JWT过滤器加到过滤器链中合适的位置(通常是在用户名密码认证过滤器之前)。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and() // 处理跨域,API项目通常需要
.csrf().disable() // 无状态API通常禁用CSRF
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll() // 登录注册接口放行
.anyRequest().authenticated() // 其他所有API都需要认证
.and()
// 将JWT过滤器加到UsernamePasswordAuthenticationFilter之前
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
第四步:创建认证接口 最后,我们需要提供一个接口让用户用用户名密码换取JWT。
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenProvider tokenProvider;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
// 1. 使用Spring Security的AuthenticationManager进行认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
)
);
// 2. 认证成功,将Authentication存入安全上下文(可选,但有时需要)
SecurityContextHolder.getContext().setAuthentication(authentication);
// 3. 生成JWT
String jwt = tokenProvider.generateToken(authentication);
// 4. 返回Token
return ResponseEntity.ok(new JwtAuthenticationResponse(jwt));
}
}
这样一套下来,一个基于Spring Security + JWT的无状态认证授权系统就搭建完成了。前端在登录后拿到Token,后续请求都在Header里带上 Authorization: Bearer <token>,后端就能识别用户身份并完成权限校验。实测下来非常稳定,也是目前业界最主流的方案之一。
5. 密码安全与加密策略演进
密码安全是系统的生命线。Spring Security在密码处理上给了我们很大的灵活性和很强的工具。最开始我们用了不安全的 withDefaultPasswordEncoder,后来提到了 BCryptPasswordEncoder,但实际生产环境更复杂:我们可能有老系统迁移过来的用MD5加密的密码,新用户又想用更安全的BCrypt,怎么办?Spring Security提供了一个“密码编码器委派”的完美解决方案——DelegatingPasswordEncoder。
你可以把它理解成一个“路由器”或者“分发器”。它根据密码字符串的前缀(ID)来决定用哪个具体的编码器去校验。比如,密码存成 {bcrypt}$2a$10$...,它看到 {bcrypt} 前缀,就会用 BCryptPasswordEncoder 去校验;存成 {MD5}5f4dcc3b5aa765d61d8327deb882cf99,就会用 Md4PasswordEncoder 去校验。
如何配置一个兼容多算法的DelegatingPasswordEncoder?
@Bean
public PasswordEncoder passwordEncoder() {
// 定义支持的编码器ID和对应的实例
String idForEncode = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder()); // 已弃用,仅作示例
// 创建DelegatingPasswordEncoder,指定默认的编码器ID
DelegatingPasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
// 设置一个默认的编码器,用于处理没有ID前缀的密码(比如老系统的遗留密码)
passwordEncoder.setDefaultPasswordEncoderForMatches(new MessageDigestPasswordEncoder("MD5"));
return passwordEncoder;
}
在实际业务中如何使用?
- 新用户注册/修改密码:直接调用
passwordEncoder.encode(rawPassword)。由于我们默认编码器ID是bcrypt,它生成的密码会自动带上前缀{bcrypt},后面跟着BCrypt哈希值。 - 老用户登录:假设老用户密码在数据库里是纯MD5哈希值(没有前缀)。当用户登录时,
DelegatingPasswordEncoder发现密码字符串没有{id}格式的前缀,就会 fallback 到我们设置的defaultPasswordEncoderForMatches,也就是MD5编码器去进行匹配。匹配成功后,你可以(也应该)在此时用新的编码器(如BCrypt)重新加密密码并更新数据库,这样下次用户登录就用上新算法了,实现了平滑升级。
@Service
public class AuthService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserRepository userRepository;
public boolean checkAndUpgradePassword(String rawPassword, User user) {
// 1. 匹配密码
if (passwordEncoder.matches(rawPassword, user.getPassword())) {
// 2. 判断当前存储的密码是否需要升级(检查是否有旧算法前缀或无前缀)
if (passwordEncoder.upgradeEncoding(user.getPassword())) {
// 3. 用新算法重新加密并保存
String newEncodedPassword = passwordEncoder.encode(rawPassword);
user.setPassword(newEncodedPassword);
userRepository.save(user);
}
return true;
}
return false;
}
}
这个策略非常优雅地解决了历史遗留密码问题和加密算法迭代的难题。我在一个用户量百万级的系统迁移中就用了这个方法,用户无感知地完成了从MD5到BCrypt的升级,整个过程平稳顺利。所以,强烈建议你在任何新项目开始时就直接使用 DelegatingPasswordEncoder,为未来留好扩展性。
6. 应对现代Web安全威胁:CSRF与CORS配置
在开发Web应用,特别是前后端分离的应用时,有两个“C”开头的安全问题是绕不开的:CSRF(跨站请求伪造)和CORS(跨域资源共享)。Spring Security对两者都提供了开箱即用的支持,但配置时需要根据你的架构做出正确选择。
关于CSRF:什么时候该开,什么时候该关? CSRF攻击简单说就是诱骗用户浏览器向已认证的网站发起非本意的请求。Spring Security默认是开启CSRF防护的,它的机制是在Session中存放一个CSRF Token,要求所有状态修改的请求(POST, PUT, DELETE等)都必须携带这个Token。
- 传统服务端渲染(SSR)应用:应该开启CSRF。因为你的表单由服务端生成,可以很方便地将CSRF Token嵌入到表单的隐藏域中(Thymeleaf、JSP等模板引擎自动支持),或者放到Meta标签里供JS读取。这是最经典的防护模式。
- 无状态API(如纯JWT方案):通常选择关闭CSRF。原因有二:第一,CSRF防护依赖于Session,而无状态API没有Session。第二,CSRF攻击的本质是借助浏览器的Cookie自动携带机制,而我们的JWT通常是放在请求头(Authorization)里的,标准的CSRF攻击无法构造出自定义请求头。因此,风险较低。这也是为什么我们在JWT配置示例中禁用了CSRF。
如何为传统应用配置CSRF? 其实默认就是开启的,你几乎不需要额外配置。只需要确保你的表单提交包含了Token。
<!-- 在Thymeleaf模板中 -->
<form th:action="@{/logout}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<button type="submit">退出登录</button>
</form>
如果你的前端是JavaScript框架(如React, Vue),你需要从服务端获取这个Token(通常可以通过一个接口返回,或者放在页面的Meta标签里),然后在发起Ajax请求时,将其作为一个Header(通常是 X-CSRF-TOKEN)或者参数带上。
关于CORS:前后端分离的必备配置
CORS是浏览器为了安全而实施的一种同源策略扩展。当前端应用(http://localhost:3000)访问后端API(http://localhost:8080)时,浏览器会先发一个“预检请求”(OPTIONS)来询问服务器是否允许跨域。Spring Security提供了两种方式配置CORS:
方式一:在Spring Security配置中集成(推荐) 这种方式将CORS过滤器整合进了Security的过滤器链,能和安全策略更好地协同。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors() // 启用CORS配置
.and()
// ... 其他安全配置
}
// 定义一个CorsConfigurationSource Bean
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://your-production-app.com")); // 允许的源
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); // 允许的方法
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type", "X-CSRF-TOKEN")); // 允许的请求头
configuration.setAllowCredentials(true); // 允许携带凭证(如Cookie),如果前端需要,此项很重要
configuration.setMaxAge(3600L); // 预检请求缓存时间(秒)
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 对所有路径生效
return source;
}
方式二:使用Spring MVC的 @CrossOrigin 注解
你可以在Controller类或方法上直接加 @CrossOrigin 注解。这种方式更细粒度,但不如全局配置方便统一管理。注意:如果同时使用了Spring Security的CORS配置和 @CrossOrigin,Spring Security的配置优先级更高。
我踩过的一个大坑:当 setAllowCredentials(true)(允许携带Cookie等凭证)时,setAllowedOrigins 不能包含通配符 *,必须明确指定域名列表。否则浏览器会报错。这是W3C规范的要求。所以,在生产环境,务必显式列出可信的源地址。
7. 记住我、会话管理与并发控制
“记住我”(Remember-Me)功能对于改善用户体验很重要,用户关闭浏览器再打开,一段时间内无需重新登录。Spring Security提供了两种实现方式:基于简单哈希的Token和基于持久化存储的Token。
基于简单哈希的Token(默认) 这种方式将用户名、过期时间和密码(的哈希)组合起来生成一个Token,发送给浏览器保存在Cookie中。它的优点是无需数据库存储,但缺点是如果用户修改了密码,之前所有的“记住我”Token都会失效,而且服务端无法主动让某个Token失效。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.rememberMe()
.key("myAppRememberMeKey") // 必须设置一个密钥,用于增强Token安全性
.tokenValiditySeconds(86400 * 30); // 记住我30天
}
基于持久化存储的Token(更安全,推荐)
这种方式会在数据库里存一张表(persistent_logins),记录Token序列号、用户名和过期时间。每次校验都查库,安全性更高,并且可以做到让用户“在所有设备上退出登录”这样的功能。
首先,你需要建表(Spring Security有默认的建表SQL,但最好自己控制)。
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
);
然后配置使用 JdbcTokenRepositoryImpl。
@Autowired
private DataSource dataSource;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(86400 * 30);
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 第一次启动时可以设置此项来创建表,之后要注释掉,否则会报表已存在的错误
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
会话管理与并发控制 有时我们需要限制同一个账号同时在线的会话数量,防止账号被共享或盗用。Spring Security可以很方便地实现。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 需要时创建Session
.maximumSessions(1) // 每个用户最多允许1个活跃会话
.maxSessionsPreventsLogin(false) // false表示新登录会踢掉旧的,true表示阻止新登录
.expiredUrl("/login?expired") // 会话过期后被踢下线的用户跳转的地址
.and()
.invalidSessionUrl("/login?invalid"); // Session失效跳转地址
}
这个配置表示一个账号只能在一个地方登录。如果张三在电脑A登录了,又在手机B登录,那么电脑A的会话会立即失效,下次请求会被重定向到 /login?expired。maxSessionsPreventsLogin(true) 则更严格,手机B的登录会被直接拒绝,适合对安全性要求极高的场景。
要实现会话并发控制,你需要将Session存储到共享介质中,比如Redis,并配置 SessionRegistry。这里涉及到Spring Session的集成,篇幅所限不展开,但思路是清晰的:通过 HttpSessionEventPublisher 发布会话事件,让 SessionRegistry 能够追踪所有活跃会话。
8. 异常处理与自定义响应
Spring Security在认证授权过程中抛出的异常(如 AccessDeniedException, AuthenticationException)默认会跳转到特定的错误页,这对于API项目来说很不友好,我们需要返回结构化的JSON错误信息。
自定义认证失败(登录失败)处理
当用户登录失败(用户名密码错误、账户被锁定等),我们可以实现 AuthenticationFailureHandler。
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> data = new HashMap<>();
data.put("timestamp", new Date());
data.put("status", HttpStatus.UNAUTHORIZED.value());
data.put("error", "Unauthorized");
data.put("message", exception.getMessage()); // 谨慎返回具体信息,可能暴露系统细节
data.put("path", request.getRequestURI());
// 更细粒度的错误信息
if (exception instanceof BadCredentialsException) {
data.put("message", "用户名或密码错误");
} else if (exception instanceof LockedException) {
data.put("message", "账户已被锁定,请联系管理员");
}
ObjectMapper mapper = new ObjectMapper();
response.getWriter().write(mapper.writeValueAsString(data));
}
}
然后在表单登录配置中指定这个失败处理器。
.formLogin()
.loginPage("/login")
.successHandler(authenticationSuccessHandler) // 也可以自定义成功处理器
.failureHandler(customAuthenticationFailureHandler) // 使用自定义失败处理器
自定义权限不足(访问被拒绝)处理
当已认证用户访问没有权限的资源时,会抛出 AccessDeniedException,我们需要实现 AccessDeniedHandler。
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> data = new HashMap<>();
data.put("timestamp", new Date());
data.put("status", HttpStatus.FORBIDDEN.value());
data.put("error", "Forbidden");
data.put("message", "抱歉,您没有权限访问此资源");
data.put("path", request.getRequestURI());
ObjectMapper mapper = new ObjectMapper();
response.getWriter().write(mapper.writeValueAsString(data));
}
}
在 HttpSecurity 配置中指定它。
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler)
自定义未认证(未登录)处理
当未认证用户访问受保护资源时,默认行为是重定向到登录页。对于API,我们需要返回401状态码和JSON。这可以通过实现 AuthenticationEntryPoint 来实现,逻辑和 AccessDeniedHandler 类似,设置状态码为 HttpStatus.UNAUTHORIZED。
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint) // 处理未认证
.accessDeniedHandler(customAccessDeniedHandler) // 处理权限不足
通过自定义这些处理器,你的API就能返回前端期望的、统一的错误响应格式了,这对于前后端联调至关重要。我在项目中通常会定义一个通用的 Result 包装类,在成功和失败时都返回相同结构的数据,让前端处理起来非常方便。
413



被折叠的 条评论
为什么被折叠?



