一、自定义匿名用户访问返回处理:AnonymousAuthenticationEntryPoint
核心作用
当未登录的匿名用户访问需要认证的接口时,替代 Security 默认的「401 页面响应」,返回自定义的 JSON 格式提示(明确告知 “未登录”),适配前后端分离场景(前端需解析 JSON 做跳转或提示)。
/** * 匿名用户访问处理 * 核心:拦截匿名用户的未认证请求,返回统一JSON格式的“未登录”提示 * * @author luoyuanxiang */ @Slf4j @Component // 注入Spring容器,供Security配置使用 public class AnonymousAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { // 1. 日志记录:打印访问失败的接口路径和异常信息,方便排查问题 log.info("用户需要登录,访问[{}]失败,AuthenticationException={}", request.getRequestURI(), authException.getMessage(), authException); // 2. 跨域配置:允许前端跨域请求(前后端分离必配,否则前端无法解析响应) response.setHeader("Access-Control-Allow-Origin", "*"); // 3. 响应格式:指定返回JSON,避免前端解析乱码 response.setHeader("Content-type", "application/json;charset=UTF-8"); // 4. 自定义响应体:使用项目统一的Result工具类,状态码424(自定义,区分“未登录”和其他401场景) Result<String> object = Result.error(424, "未登录,请登录访问"); // 5. 写入响应:将Result转为JSON字符串返回 response.getWriter().print(JSONUtil.toJsonStr(object)); } }
二、自定义权限访问异常处理:CustomAccessDeniedHandler
核心作用
当已登录但权限不足的用户访问接口时(比如普通用户访问管理员接口),替代 Security 默认的「403 页面响应」,返回自定义 JSON 格式的 “无权限” 提示,适配前后端分离。
/** * 授权异常处理 * 核心:拦截已登录用户的“权限不足”请求,返回统一JSON格式的“无权限”提示 * * @author luoyuanxiang */ @Component // 注入Spring容器,供Security配置使用 public class CustomAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { // 1. 自定义响应体:状态码403(HTTP标准“禁止访问”码),提示“无权限” Result<Object> error = Result.error(403, "无权限访问"); // 2. 跨域与响应格式配置:同匿名用户处理,确保前端能解析 response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Content-type", "application/json;charset=UTF-8"); // 3. 写入响应:返回JSON response.getWriter().print(JSONUtil.toJsonStr(error)); } }
三、自定义跳过认证注解类和 AOP 实现
3.1 免认证注解:NoAuth
核心作用
定义一个「标记注解」,用于标注不需要登录就能访问的接口 / 控制器(比如登录接口、注册接口、验证码接口),替代传统的 “在 Security 配置中硬编码白名单路径”,更灵活易维护。
/** * 无需认证token注解 * 核心:标记接口/控制器,告知Security“该路径免登录访问” * * @author luoyuanxiang */ @Documented // 生成API文档时,显示该注解 @Retention(RetentionPolicy.RUNTIME) // 注解保留到运行时(必须,因为需要在运行时扫描) @Target({ElementType.METHOD, ElementType.TYPE}) // 注解可用于:方法(单个接口)、类(整个控制器) public @interface NoAuth { }
3.2 免认证路径收集:RequestMappingCollector
核心作用
通过实现 Spring 的 BeanPostProcessor
接口,在项目启动时自动扫描所有加了 @NoAuth 注解的接口路径,收集成 “免认证白名单”,供 Security 配置使用(避免手动写死白名单)。
/** * 免认证token配置(原注释“无线”应为笔误) * 核心:项目启动时扫描@NoAuth注解,自动收集免认证路径,生成白名单 * * @author luoyuanxiang */ @Service // 注入Spring容器,启动时自动执行BeanPostProcessor逻辑 public class RequestMappingCollector implements BeanPostProcessor { // 存储免认证路径的白名单:用LinkedHashSet保证顺序且去重(避免同一路径重复添加) @Getter @Setter private Set<String> permitAllUrls = new LinkedHashSet<>(); @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { // 1. 判断当前Bean是否是“请求映射处理器”(负责管理所有@RequestMapping接口的Bean) if (bean instanceof RequestMappingHandlerMapping handlerMapping) { // 2. 获取所有接口的“请求映射信息”(key:映射规则,value:对应的方法) Map<RequestMappingInfo, HandlerMethod> handlerMethods = handlerMapping.getHandlerMethods(); // 3. 遍历所有接口,判断是否加了@NoAuth for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethods.entrySet()) { RequestMappingInfo info = entry.getKey(); // 接口的路径信息(如@RequestMapping("/login")) HandlerMethod method = entry.getValue(); // 接口对应的Controller方法 // 4. 检查:Controller类或方法是否加了@NoAuth注解 boolean hasNoAuth = AnnotationUtils.findAnnotation(method.getBeanType(), NoAuth.class) != null // 类上的注解 || AnnotationUtils.findAnnotation(method.getMethod(), NoAuth.class) != null; // 方法上的注解 // 5. 若加了@NoAuth,将接口路径加入白名单 if (hasNoAuth) { // 获取接口的所有路径(如@RequestMapping({"/a","/b"})会返回两个路径) permitAllUrls.addAll(info.getPathPatternsCondition().getPatternValues()); } } } // 6. BeanPostProcessor接口要求返回原Bean,不修改Bean本身 return bean; } }
四、自定义实现登录用户信息:SecurityUser
核心作用
继承 Security 提供的 User
类(实现 UserDetails
接口),扩展存储项目自定义的用户实体(UserEntity) —— 因为 Security 默认的 User
只包含用户名、密码、权限,无法满足业务需求(比如需要用户 ID、昵称等)。
/** * 登录用户详情 * 核心:扩展Security的User类,关联项目自定义的UserEntity,存储完整用户信息 * * @author luoyuanxiang */ @Getter @Setter public class SecurityUser extends User { // 继承Security的User,自动实现UserDetails接口 // 扩展字段:关联项目自定义的用户实体(存储用户ID、昵称、角色等业务字段) private UserEntity userEntity; // 构造方法:调用父类构造(传递Security必需的用户名、密码、权限) public SecurityUser(String username, String password, Collection<? extends GrantedAuthority> authorities) { super(username, password, authorities); } }
五、自定义实现 UserDetailsService
接口:UserDetailsServiceImpl
核心作用
实现 Security 的 UserDetailsService
接口,是用户认证的核心数据源—— 当用户登录时,Security 会调用该类的 loadUserByUsername
方法,从数据库查询用户信息,供后续密码校验和权限赋值。
/** * 实现 UserDetailsService 接口的用户详情服务类 * 核心:登录时查询数据库获取用户信息,封装成Security能识别的SecurityUser * * @author luoyuanxiang */ @Service // 注入Spring容器,供Security的AuthenticationManager使用 public class UserDetailsServiceImpl implements UserDetailsService { // 注入项目的用户业务服务(用于从数据库查用户) @Resource private IUserService userService; @Override public SecurityUser loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 根据用户名查询数据库:调用业务层方法,获取自定义UserEntity UserEntity userEntity = userService.findByUsername(username); // 2. 若用户不存在,抛出Security标准异常(会被后续异常处理器捕获,返回“用户不存在”) if (userEntity == null) { throw new UsernameNotFoundException("用户不存在:" + username); } // 3. 构建用户权限集合:从UserEntity中获取权限编码(如“user:list”),转为Security的GrantedAuthority Collection<GrantedAuthority> authorities = new ArrayList<>(); if (StrUtil.isNotBlank(userEntity.getPermissionsCode())) { // 若用户有权限编码(非空) // 拆分权限编码(假设数据库存储格式为“user:list,user:add”) for (String code : userEntity.getPermissionsCode().split(",")) { authorities.add(new SimpleGrantedAuthority(code)); // 每个编码对应一个权限 } } // 4. 封装成SecurityUser:关联UserEntity,传递用户名、密码(数据库存储的加密后密码)、权限 SecurityUser securityUser = new SecurityUser(userEntity.getUsername(), userEntity.getPassword(), authorities); securityUser.setUserEntity(userEntity); // 5. 返回SecurityUser:供Security后续校验密码、设置认证信息 return securityUser; } }
六、JWT 工具类和 JWT 过滤器实现
6.1 JWT 工具类:JwtUtils
核心作用
封装 JWT 的核心操作:生成 Token(登录成功后返回给前端)、解析 Token(从请求头提取用户信息)、验证 Token(是否过期、签名是否合法),是前后端分离认证的 “核心工具”。
/** * JWT 工具类,用于生成、解析和验证 JWT token * 核心:处理JWT的全生命周期,依赖配置文件中的密钥和过期时间 * * @author luoyuanxiang */ @Component // 注入Spring容器,供登录接口和JWT过滤器使用 public class JwtUtils { // 从配置文件读取JWT密钥(如application.yml中的jwt.secret) @Value("${jwt.secret}") private String jwtSecret; // 从配置文件读取JWT过期时间(毫秒,如3600000=1小时) @Value("${jwt.expiration}") private int jwtExpirationMs; /** * 从Token中提取用户名(Subject字段,JWT标准字段) */ public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } /** * 从Token中提取过期时间(Expiration字段,JWT标准字段) */ public Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } /** * 通用方法:从Token中提取指定的“声明”(Claim,JWT的自定义字段或标准字段) */ public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); // 先提取所有声明 return claimsResolver.apply(claims); // 根据传入的函数获取指定声明 } /** * 提取Token中的所有声明(包括标准字段和自定义字段) */ private Claims extractAllClaims(String token) { // JWT解析器构建:设置签名密钥(必须和生成Token时一致,否则解析失败) return Jwts.parserBuilder() .setSigningKey(key()) // 传入密钥(见下方key()方法) .build() .parseClaimsJws(token) // 解析Token(若签名不合法、格式错误,会抛异常) .getBody(); // 获取声明体 } /** * 检查Token是否过期 */ public Boolean isTokenExpired(String token) { // 比较Token的过期时间和当前时间:若过期时间在当前时间之前,说明已过期 return extractExpiration(token).before(new Date()); } /** * 生成JWT Token(登录成功后调用) * @param userDetails:Security的用户详情(包含用户名、权限等) */ public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); // 自定义声明(可添加用户ID、角色等) return createToken(claims, userDetails.getUsername()); // 传入自定义声明和用户名(Subject) } /** * 生成签名密钥:将配置文件中的字符串密钥转为JWT要求的SecretKey(Base64解码) */ private SecretKey key() { byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); // 解码Base64格式的密钥 return Keys.hmacShaKeyFor(keyBytes); // 生成HS256算法的密钥(和生成Token时的签名算法一致) } /** * 实际创建Token的方法(封装JWT标准字段) */ private String createToken(Map<String, Object> claims, String subject) { return Jwts.builder() .setClaims(claims) // 设置自定义声明 .setSubject(subject) // 设置用户名(Subject,JWT标准字段) .setIssuedAt(new Date(System.currentTimeMillis())) // 设置签发时间(标准字段) .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs)) // 设置过期时间 .signWith(key(), SignatureAlgorithm.HS256) // 设置签名算法和密钥 .compact(); // 生成Token字符串 } /** * 验证Token合法性:1. 用户名匹配 2. Token未过期 */ public Boolean validateToken(String token, UserDetails userDetails) { final String username = extractUsername(token); // 从Token提取用户名 // 验证:用户名和userDetails中的一致 + Token未过期 return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } /** * 从请求头中解析Token:前端需将Token放在Authorization头,格式为“Bearer {token}” */ public String parseJwt(HttpServletRequest request) { String headerAuth = request.getHeader("Authorization"); // 获取Authorization头 // 检查头信息是否符合“Bearer ”前缀(前后端约定的格式) if (headerAuth != null && headerAuth.startsWith("Bearer ")) { return headerAuth.substring(7); // 截取“Bearer ”后的Token字符串(7是“Bearer ”的长度) } return null; // 无Token或格式错误,返回null } }
6.2 JWT 过滤器:JwtAuthenticationFilter
核心作用
继承 Security 的 OncePerRequestFilter
(确保每次请求只执行一次),拦截所有请求,从请求头提取 JWT Token,验证合法性后将用户信息存入 SecurityContext
(让 Security 后续能识别 “当前用户已登录”)。
/** * JWT 认证过滤器,用于拦截请求并验证 JWT token * 核心:每次请求前验证Token,合法则设置认证信息,让Security识别已登录用户 * * @author luoyuanxiang */ public class JwtAuthenticationFilter extends OncePerRequestFilter { // 确保单次请求只执行一次过滤 // 注入JWT工具类(解析、验证Token) @Resource private JwtUtils jwtUtils; // 注入UserDetailsService(验证Token时需查询用户信息) @Resource private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 1. 从请求头解析Token String jwt = jwtUtils.parseJwt(request); // 2. 若Token不为null(存在Token) if (Objects.nonNull(jwt)) { // 3. 从Token提取用户名 String username = jwtUtils.extractUsername(jwt); // 4. 从数据库查询用户信息(封装成UserDetails) UserDetails userDetails = userDetailsService.loadUserByUsername(username); // 5. 验证Token合法性(用户名匹配 + 未过期) if (jwtUtils.validateToken(jwt, userDetails)) { // 6. 构建认证信息:Security的UsernamePasswordAuthenticationToken(存储用户信息和权限) UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); // 密码设为null(已通过Token验证,无需密码) // 设置请求详情(如IP、会话ID,可选但建议加) authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 7. 将认证信息存入SecurityContext:后续Security会从这里获取当前用户信息 SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // 8. 捕获异常(如Token无效、过期):打印日志,不中断过滤器链(让后续流程处理未认证情况) logger.error("Cannot set user authentication: {}", e); } // 9. 继续执行过滤器链:无论是否有认证信息,都让请求进入下一个过滤器(如授权过滤器) filterChain.doFilter(request, response); } }
七、Spring Security 配置:SecurityConfig
核心作用
Security 的核心配置类:整合上述所有自定义组件(过滤器、异常处理器、白名单),配置认证授权规则(哪些路径免认证、哪些需权限)、跨域、会话管理等,是整个 Security 流程的 “总指挥”。
/** * Spring Security 配置类 * 核心:整合所有自定义组件,定义认证授权规则,配置Security核心流程 * * @author luoyuanxiang */ @Configuration // 标记为配置类 @EnableWebSecurity // 启用Spring Security功能 @EnableMethodSecurity // 启用方法级别的权限控制(如@PreAuthorize("hasRole('ADMIN')")) public class SecurityConfig { // 注入免认证路径收集器(存储@NoAuth标注的路径) @Resource private RequestMappingCollector requestMappingCollector; // 注入匿名用户异常处理器 @Resource private AnonymousAuthenticationEntryPoint anonymousAuthenticationEntryPoint; // 注入权限不足异常处理器 @Resource private CustomAccessDeniedHandler customAccessDeniedHandler; /** * 自定义权限服务(假设项目需要自定义权限表达式,如@PreAuthorize("@pms.hasPerm('user:list')")) * 核心:提供自定义权限校验逻辑,供方法级注解使用 */ @Bean("pms") // 命名为pms,对应注解中的@pms public PermissionService permissionService() { return new PermissionService(); } /** * 支持自定义权限表达式(配合@EnableMethodSecurity) * 核心:让Security识别自定义的权限服务(如@pms) */ @Bean public AnnotationTemplateExpressionDefaults prePostTemplateDefaults() { return new AnnotationTemplateExpressionDefaults(); } /** * 注册JWT过滤器到Spring容器 */ @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { return new JwtAuthenticationFilter(); } /** * 跨域配置(前后端分离必配) * 核心:定义允许的跨域规则(域名、方法、请求头),替代之前处理器中的硬编码跨域头 */ @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); // 允许所有域名(生产环境建议指定具体域名,如"http://localhost:8080") configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); // 允许的HTTP方法 configuration.setAllowedHeaders(List.of("*")); // 允许的请求头(如Authorization、Content-Type) configuration.setAllowCredentials(true); // 允许携带Cookie(若前端需要传Cookie,需开启) // 注册跨域规则:所有路径都适用 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } /** * 核心配置:定义Security的过滤器链(认证授权流程) */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 1. 配置跨域:使用上述corsConfigurationSource .cors(cors -> cors.configurationSource(corsConfigurationSource())) // 2. 禁用CSRF:前后端分离项目中,CSRF令牌难以传递,且JWT本身已防篡改,故禁用 .csrf(AbstractHttpConfigurer::disable) // 3. 会话管理:设置为无状态(STATELESS),因为JWT是无状态认证,不依赖Session .sessionManagement(se -> se.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 4. 授权规则配置 .authorizeHttpRequests(auth -> auth // 4.1 免认证路径:从requestMappingCollector获取@NoAuth标注的路径,允许匿名访问 .requestMatchers(requestMappingCollector.getPermitAllUrls().toArray(new String[0])) .permitAll() // 4.2 其他所有路径:必须认证(已登录)才能访问 .anyRequest() .authenticated()) // 5. 禁用默认登录页面:前后端分离用自定义登录接口(/login),故禁用Security默认表单登录 .formLogin(AbstractHttpConfigurer::disable) // 6. 禁用默认退出:后续可自定义退出接口(如/logout,将Token加入黑名单),故禁用默认退出 .logout(AbstractHttpConfigurer::disable) // 7. 异常处理:配置匿名用户和权限不足的异常处理器 .exceptionHandling(exception -> { exception.authenticationEntryPoint(anonymousAuthenticationEntryPoint); // 未登录异常 exception.accessDeniedHandler(customAccessDeniedHandler); // 权限不足异常 }) // 8. 添加JWT过滤器:将JWT过滤器放在UsernamePasswordAuthenticationFilter之前(先验证Token,再执行后续认证) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); // 返回配置好的过滤器链 return http.build(); } /** * 密码编码器:使用BCrypt算法加密密码(Security推荐,不可逆,带盐值) * 核心:登录时Security会自动用该编码器校验密码(数据库存储BCrypt加密后的密码) */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 注册认证管理器:供登录接口使用(调用authenticate方法校验用户名密码) */ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } }
八、登录实现:AuthController
核心作用
提供自定义登录接口(/login
)和 Token 校验接口(/check
),是用户触发认证流程的入口:接收前端传入的用户名密码,调用 Security 的 AuthenticationManager
校验,成功后生成 JWT 返回给前端。
/** * 登录控制器 * 核心:提供自定义登录接口和Token校验接口,对接前端认证需求 * * @author luoyuanxiang */ @RestController // 标记为REST接口控制器 public class AuthController { // 注入认证管理器(用于校验用户名密码) @Resource private AuthenticationManager authenticationManager; // 注入JWT工具类(生成Token) @Resource private JwtUtils jwtUtils; // 注入用户业务服务(可选,视业务需求) @Resource private IUserService userService; // 注入HttpServletRequest(用于从请求头提取Token,供/check接口使用) @Resource private HttpServletRequest request; /** * 用户登录接口(核心) * @NoAuth:标记为免认证路径(未登录用户也能访问) * @RequestBody:接收前端传入的JSON格式登录参数(用户名、密码) */ @NoAuth @PostMapping("/login") public Result<JwtResponse> login(@RequestBody LoginRequest loginRequest) { // 1. 调用AuthenticationManager校验用户名密码: // 传入UsernamePasswordAuthenticationToken(封装用户名密码),内部会调用UserDetailsService查用户,并用PasswordEncoder校验密码 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password())); // 2. 校验成功:将认证信息存入SecurityContext(供后续流程使用,如JWT过滤器) SecurityContextHolder.getContext().setAuthentication(authentication); // 3. 获取当前登录用户的详情(SecurityUser,包含自定义的UserEntity) SecurityUser userDetails = (SecurityUser) authentication.getPrincipal(); // 4. 生成JWT Token(传入SecurityUser,包含用户名和权限) String jwt = jwtUtils.generateToken(userDetails); // 5. 敏感信息处理:清空UserEntity中的密码(避免返回给前端) userDetails.getUserEntity().setPassword("只有聪明的人才能看到密码"); // 占位符,实际可设为null // 6. 返回登录结果:包含用户信息、角色、Token(用自定义JwtResponse封装) return Result.success(new JwtResponse(userDetails.getUserEntity(), userDetails.getUserEntity().getRole(), jwt)); } /** * Token校验接口(可选) * 作用:前端可定期调用该接口,检查Token是否有效/过期 */ @GetMapping("/check") public Result<String> check() { try { // 1. 从请求头提取Token String token = jwtUtils.parseJwt(request); // 2. 检查Token是否过期 boolean tokenExpired = jwtUtils.isTokenExpired(token); // 3. 返回结果:过期则提示“token已过期”,否则返回成功 return tokenExpired ? Result.error(424, "token已过期") : Result.success(); } catch (Exception ignored) { // 4. 捕获异常(如Token无效、格式错误):返回“token无效” } return Result.error(424, "token 无效"); } /** * 登录请求体(使用Java Record,简洁替代POJO) * 核心:封装前端传入的登录参数(用户名、密码),实现Serializable便于序列化 */ public record LoginRequest(String username, String password) implements Serializable { } /** * 登录响应体(使用Java Record) * 核心:封装登录成功后返回给前端的数据(用户信息、角色、Token) */ public record JwtResponse(UserEntity user, RoleEntity role, String token) { } }
整体流程总结
- 未登录访问需认证接口:JWT 过滤器未提取到有效 Token → Security 触发
AnonymousAuthenticationEntryPoint
→ 返回 “未登录” JSON。 - 已登录但权限不足:JWT 验证通过,但用户权限不匹配 → Security 触发
CustomAccessDeniedHandler
→ 返回 “无权限” JSON。 - 登录流程:前端调用
/login
(@NoAuth 免认证)→ 传入用户名密码 →AuthenticationManager
校验 → 成功生成 JWT → 返回给前端。 - 已登录访问接口:前端在 Authorization 头携带 JWT → JWT 过滤器解析验证 Token → 合法则设置认证信息 → Security 允许访问接口。
评论区
评论加载中...