返回

Spring Security认证成功但授权失败? 正确返回403指南

java

Spring Security: 认证成功但授权失败?返回 403 的正确姿势

哥们儿,遇到个挺常见的 Spring Security 问题:用户登录输对了账号密码(认证成功),但没权限访问他选的那个项目(授权失败),这时候你希望给个 403 Forbidden,而不是默认的 500 错误页面。而且,如果一开始密码就错了,那得先提示“账号密码无效”,不能直接跳到权限不足。

听起来有点绕?别急,这场景在多租户或者按项目划分权限的系统里挺常见的。用户登录时不仅要验身份,还得看他选的“目的地”对不对。

问题来了

看看这段代码,你可能也写过类似的路子:

有个 SecurityConfig,里面配置了 formLogin(),还特别定义了一个 AuthenticationSuccessHandler。在这个 successHandler 里,用户认证通过后,咱就去检查他有没有登录所选项目的权限(比如,检查 PermissionAliasConstants.LOGIN 权限)。如果没权限,就 throw new AccessDeniedException(...)

// SecurityConfig.java (部分代码)
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        // ... 省略部分配置
        .formLogin().successHandler(successHandler()).loginPage("/login").permitAll()
        .and().exceptionHandling().accessDeniedHandler(accessDeniedHandler()) // <--- 看这里
        // ... 省略部分配置
}

private AuthenticationSuccessHandler successHandler() {
    return (request, response, authentication) -> {
        CurrentUser user = (CurrentUser) authentication.getPrincipal();
        String selectedProjectId = request.getParameter(Constants.SESSION_PROJECT_ID); // 假设项目 ID 是这么传的
        // 实际项目中, Project 的获取和检查逻辑可能更复杂
        Project selectedProject = projectService.getProjectById(Integer.valueOf(selectedProjectId));
        user.setProject(selectedProject); // 假设 CurrentUser 需要知道当前项目

        // 检查登录特定项目的权限
        if (!user.hasAccessRight(PermissionAliasConstants.LOGIN)) {
            String message = String.format("用户 \"%s\" 没有权限登录项目 \"%s\"", user.getUsername(), selectedProject.getName());
            // 问题就在这儿:直接抛异常了
            throw new AccessDeniedException(message);
        }
        // 如果有权限,正常处理,比如重定向到首页
        // response.sendRedirect("/home"); // 或者是你的默认成功 URL
        String targetUrl = "/"; // 根据你的逻辑确定成功后的跳转地址
        request.getRequestDispatcher(targetUrl).forward(request, response);
    };
}

private AccessDeniedHandler accessDeniedHandler() {
    // 这个 Handler 按理说应该处理 AccessDeniedException
    return (request, response, e) -> {
        logger.debug("Access Denied Handler 触发!消息: \"{}\"", e.getMessage());
        response.sendError(HttpStatus.FORBIDDEN.value(), "你没权限访问这个资源"); // 返回 403
    };
}

同时,UserDetailsServiceImpl 里也做了调整,认证时会根据请求参数里的项目 ID (projectId) 来加载用户在该项目下的具体权限。

// UserDetailsServiceImpl.java (部分代码)
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // ... 获取 user 对象 ...

    // 从请求里拿到用户选择的项目 ID
    final String projectId = request.getParameter(Constants.SESSION_PROJECT_ID);
    if (projectId == null || projectId.isEmpty()) {
        // 实际应用中应该处理 projectId 不存在的情况,可能抛出特定异常或使用默认项目
        throw new UsernameNotFoundException("登录时必须选择项目");
    }
    Project project = projectService.getProjectById(Integer.valueOf(projectId));

    // 加载用户在此项目下的权限
    List<AccessRightsPermission> accessRights = accessRightsService.getAccessRightsByProject(user.getId(), project.getId());
    Set<GrantedAuthority> authorities = new HashSet<>();
    authorities.addAll(accessRights.stream()
                                .map(right -> new SimpleGrantedAuthority(right.getAlias()))
                                .collect(Collectors.toList()));

    // 创建包含项目信息的 CurrentUser
    final CurrentUser currentUser = new CurrentUser(user, project, authorities);

    // 这里不能检查 LOGIN 权限,否则密码对但项目权限不对时,也会报“密码错误”
    // (因为认证流程会捕获 UserDetailsService 里的异常作为认证失败)

    logger.info("认证准备就绪: 用户 {} 尝试登录项目 {}", username, project.getName());
    return currentUser;
}

理论很美好,现实很骨感。实际跑起来,当 successHandler 抛出 AccessDeniedException 时,配置的 accessDeniedHandler 根本不鸟它,最后浏览器直接给你一个 HTTP 500 (Internal Server Error) 的页面,控制台可能还会打印一堆异常堆栈。

这可不是咱想要的!咱要的是一个干净利落的 403 Forbidden,最好还能带上为啥被拒的提示信息,比如 “用户 Tural 没有权限登录项目 AZB”。

为什么会这样? (原因分析)

要弄明白为啥 accessDeniedHandler 没接住这个异常,得了解一下 Spring Security 处理请求的流程和 exceptionHandling() 的作用范围。

  1. 认证流程: 用户提交用户名、密码、项目 ID -> UsernamePasswordAuthenticationFilter 拦截 -> 调用 AuthenticationManager -> AuthenticationManager 使用配置的 UserDetailsService (也就是咱的 UserDetailsServiceImpl) 加载用户信息和权限 -> 验证密码。
  2. 认证成功后: 如果上面一步没抛异常(比如 UsernameNotFoundExceptionBadCredentialsException),认证就算成功了。这时轮到 AuthenticationSuccessHandler (咱自己配的 successHandler) 上场。
  3. AuthenticationSuccessHandler 的职责: 它的主要任务是决定认证成功后干啥,通常是重定向到某个页面(比如用户首页 /home)。
  4. exceptionHandling().accessDeniedHandler() 的守备范围: 这个配置主要是用来处理授权阶段 抛出的 AccessDeniedException。啥是授权阶段?通常是认证成功后,访问某个受保护资源时,Spring Security 根据配置(比如 @PreAuthorize, @Secured, 或 http.authorizeRequests().antMatchers(...).hasRole(...) 等)检查用户有没有足够权限。如果检查不通过,就会抛出 AccessDeniedException,这时候 accessDeniedHandler 就派上用场了,捕获这个异常,返回 403。
  5. 问题症结: 咱是在 AuthenticationSuccessHandler 里面 手动 抛出的 AccessDeniedException。这个时间点,对于 Spring Security 的核心过滤器链来说,认证已经完成,正准备进行“成功后操作”(比如重定向)。这个异常并没有发生在它预期的“授权检查”环节,更像是在成功处理逻辑内部发生了一个意外。默认情况下,Spring Security 的 ExceptionTranslationFilter(负责协调认证和授权异常处理的)可能没覆盖到这个角落,或者说 successHandler 内部的异常被当成了普通的未处理异常,最终导致了容器层面的 500 错误。

简单说,就是在错误的时间、错误的地点抛出了“对的”异常,导致处理这个异常的“专职警察”(accessDeniedHandler)没到岗。

怎么解决? (解决方案)

既然知道了原因,解决起来就有方向了。咱得确保在认证成功、但项目授权失败时,能稳定地返回 403。

方案一:在 AuthenticationSuccessHandler 里直接 “发卡” (推荐)

这是最直接的办法。别抛异常让别人去擦屁股了,咱自己动手,丰衣足食。在 successHandler 里判断没权限后,直接操作 HttpServletResponse,告诉浏览器:“嘿,你小子没权限 (403)”。

原理与作用:

不再依赖外部的异常处理机制捕获 AccessDeniedException。当检查到用户没有登录目标项目的权限时,successHandler 主动设置 HTTP 响应状态码为 403,并可以附带一条错误消息。这样就绕开了上面分析的“职责范围”问题。

代码示例:

修改你的 SecurityConfig 中的 successHandler

// SecurityConfig.java (修改后的 successHandler)
private AuthenticationSuccessHandler successHandler() {
    return (request, response, authentication) -> {
        CurrentUser user = (CurrentUser) authentication.getPrincipal();
        String selectedProjectIdParam = request.getParameter(Constants.SESSION_PROJECT_ID);

        // 健壮性考虑:检查 projectId 是否存在且有效
        if (selectedProjectIdParam == null || selectedProjectIdParam.trim().isEmpty()) {
            logger.warn("登录请求中缺少项目 ID 参数");
            // 可以选择返回 400 Bad Request 或其他错误
            response.sendError(HttpStatus.BAD_REQUEST.value(), "缺少必要的项目信息");
            return; // 结束处理
        }

        Project selectedProject;
        try {
            selectedProject = projectService.getProjectById(Integer.valueOf(selectedProjectIdParam));
            user.setProject(selectedProject); // 关联选择的项目到当前用户上下文
        } catch (NumberFormatException | DataNotFoundException e) {
            // 处理无效的项目ID或项目未找到的情况
            logger.error("无法找到或解析项目ID: {}", selectedProjectIdParam, e);
            response.sendError(HttpStatus.BAD_REQUEST.value(), "无效的项目选择");
            return; // 结束处理
        }


        // 检查登录特定项目的权限
        if (!user.hasAccessRight(PermissionAliasConstants.LOGIN)) {
            String message = String.format("用户 \"%s\" 没有权限登录项目 \"%s\"", user.getUsername(), selectedProject.getName());
            logger.warn("授权失败: {}", message); // 日志记录很重要

            // !!! 关键改动:不再抛异常,直接发送 403 错误 !!!
            response.sendError(HttpStatus.FORBIDDEN.value(), message);
            // 注意:调用 sendError 后,不应再操作 response 或进行 forward/redirect

        } else {
            // 授权成功,执行原有的成功逻辑,比如保存用户信息到 session,然后重定向
            logger.info("用户 {} 成功登录项目 {}", user.getUsername(), selectedProject.getName());
            // 示例:重定向到默认页面,或者上次访问的页面等
            // SavedRequestAwareAuthenticationSuccessHandler 是一个常用的实现,可以处理重定向逻辑
            // 这里简单重定向到根路径
            response.sendRedirect(request.getContextPath() + "/"); // 或者你的目标 URL
        }
    };
}

操作步骤:

  1. 修改 SecurityConfig.java 文件,将 successHandler() 方法替换为上面的代码。
  2. 确保你的 CurrentUser 类(如果需要)有 setProject(Project project) 方法,或者你通过其他方式在后续逻辑中能获取到用户当前登录的项目。
  3. 确保你的 ProjectServiceAccessRightsService 能正确工作。

安全建议:

  • 日志记录: 务必在检查失败并发送 403 时记录详细日志(哪个用户、尝试登录哪个项目、失败原因),方便排查问题和安全审计。
  • 错误信息: sendError 的第二个参数 message 会显示在某些容器默认的错误页面上。注意不要泄露过多敏感的系统内部信息。只给用户必要、安全的提示。

进阶使用技巧:

  • 如果你使用 AJAX 进行登录,前端 JavaScript 可以直接捕获到这个 403 状态码和错误消息,然后友好地展示给用户,而不是让浏览器跳转到一个干巴巴的 403 页面。
  • 对于非 AJAX 的传统表单登录,response.sendError() 通常会导致 Web 容器(如 Tomcat)显示其内置的 403 错误页面。如果你想展示自定义的 403 页面,需要在 web.xml (或 Spring Boot 的 ErrorPageRegistrar) 中配置错误页面映射。

方案二:重定向到受保护资源 + @PreAuthorize (间接但符合模式)

这个方案稍微绕一点,但更符合 Spring Security 的“标准”授权流程。

原理与作用:

AuthenticationSuccessHandler 不再做权限检查。它的唯一职责就是认证成功后,把用户重定向到一个需要特定权限(比如,访问仪表板 /dashboard)的 URL。然后,在这个目标 URL 对应的 Controller 方法上使用 @PreAuthorize 注解来检查用户是否有权访问“当前选择的项目”。因为这个检查发生在标准的授权流程中,如果失败,抛出的 AccessDeniedException 就能够被咱们配置的 accessDeniedHandler 正常捕获,并返回 403。

代码示例:

  1. 修改 successHandler: 只做重定向。

    // SecurityConfig.java
    private AuthenticationSuccessHandler successHandler() {
        // 可以使用 Spring Security 提供的默认实现,或者一个简单的重定向处理器
        return (request, response, authentication) -> {
            // 也可以用更智能的 handler, 比如根据角色重定向到不同页面
            // 这里简单重定向到 /dashboard,假设它是登录后的入口
            response.sendRedirect(request.getContextPath() + "/dashboard");
        };
    }
    
  2. 创建目标 Controller 和方法,并加上 @PreAuthorize:

    // DashboardController.java (或你的入口 Controller)
    import org.springframework.security.access.prepost.PreAuthorize;
    import org.springframework.security.core.annotation.AuthenticationPrincipal;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.servlet.ModelAndView;
    
    @Controller
    public class DashboardController {
    
        // 在这里进行项目登录权限的最终检查
        // 需要确保 CurrentUser 中包含了登录时选择的 Project 信息
        // 或者,你可能需要一个自定义的 PermissionEvaluator 来处理基于 Project 的权限
        // 简单的示例:假设 CurrentUser 里有 getProject() 和 hasAccessRight()
        @GetMapping("/dashboard")
        @PreAuthorize("@projectPermissionEvaluator.hasLoginAccess(authentication, #currentUser.project.id)") // 示例性的表达式, 需要自定义 Evaluator
        // 或者,如果权限是基于 Role/Authority 且已在 UserDetails 中体现:
        // @PreAuthorize("hasAuthority('LOGIN_PROJECT_' + #currentUser.project.id)") // 假设权限名包含项目ID
        // 更直接的方式,如果CurrentUser实现了所需检查逻辑:
        // @PreAuthorize("#currentUser.hasAccessRight('LOGIN')") // 前提是 CurrentUser 持有当前项目上下文
        public ModelAndView dashboard(@AuthenticationPrincipal CurrentUser currentUser) {
            // 能进入这里,说明权限检查通过
            ModelAndView mav = new ModelAndView("dashboard");
            mav.addObject("username", currentUser.getUsername());
            mav.addObject("projectName", currentUser.getProject().getName());
            // ... 其他业务逻辑
            return mav;
        }
    }
    

    注意: @PreAuthorize 里的表达式需要根据你的实际情况来写。你可能需要:

    • 确保 CurrentUser 对象 (authentication.getPrincipal()) 在此刻仍然持有用户登录时选择的 Project 信息。这可能需要在 UserDetailsServiceImpl 加载后或者 successHandler 中将其存入 HttpSessionSecurityContext (要小心处理状态)。
    • 创建一个自定义的 PermissionEvaluator (实现 org.springframework.security.access.PermissionEvaluator),并在 @PreAuthorize 中调用它,例如 @projectPermissionEvaluator.hasPermission(authentication, #projectIdFromSomewhere, 'LOGIN')。这是处理复杂对象权限(比如特定项目的权限)的标准方式。
  3. 确保 accessDeniedHandler 配置仍然有效:

    // SecurityConfig.java (保持不变)
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // ...
            .exceptionHandling().accessDeniedHandler(accessDeniedHandler()) // 这个现在能派上用场了
            // ...
    }
    
    private AccessDeniedHandler accessDeniedHandler() {
        return (request, response, e) -> {
            logger.warn("授权拒绝(通过 AccessDeniedHandler): 用户尝试访问 {}, 原因: {}", request.getRequestURI(), e.getMessage());
            // 你可以重定向到自定义的错误页面,或者直接返回 403
            response.sendError(HttpStatus.FORBIDDEN.value(), "您没有权限执行此操作或访问此项目。");
        };
    }
    

操作步骤:

  1. 修改 SecurityConfigsuccessHandler 为简单的重定向。
  2. 创建或修改你的登录后目标 Controller (如 DashboardController),在入口方法上添加 @PreAuthorize 注解,实现项目权限检查逻辑(可能需要自定义 PermissionEvaluator 或确保 CurrentUser 携带项目信息)。
  3. 确保 @EnableGlobalMethodSecurity(prePostEnabled = true) 注解在你的配置类上。
  4. 确保 accessDeniedHandler 按需配置。

优缺点:

  • 优点: 结构更清晰,职责分离。认证成功处理就是认证成功处理,授权检查交给专门的授权机制。利用了 Spring Security 的标准流程,更容易理解和维护。
  • 缺点: 多了一次重定向。实现 @PreAuthorize 中的项目权限检查可能稍微复杂一些,特别是需要传递和获取“当前选择的项目”上下文时。

如何在前端或控制器处理 403 响应?

对于方案一(直接 sendError(403)):

  • 传统表单登录: 如前所述,浏览器通常会显示容器默认的 403 页面,上面可能有你通过 sendError 传递的消息。如果想定制,需要配置全局错误页面映射。LoginControllerGET /login 方法主要用于显示登录页,它一般不直接处理 POST 登录失败后的 403 情况(除非你配置了错误页面映射指向 /login 并携带特定参数,但这比较绕)。
  • AJAX 登录: 前端 JavaScript 的 fetchXMLHttpRequest 的错误处理回调(比如 .catch()onerror / 检查 status === 403)会捕获到 403 状态码。你可以从 response 对象中(可能需要后端配合设置响应体)获取错误消息,并在页面上友好地展示给用户,例如在登录表单旁边显示红字提示。

对于方案二(@PreAuthorize + accessDeniedHandler):

  • accessDeniedHandler 的行为决定了前端如何响应。
    • 如果 accessDeniedHandler 也是 sendError(403),那么表现和方案一类似。
    • 如果 accessDeniedHandler 配置为重定向到一个特定的错误页面(比如 /access-denied),那么浏览器会跳转到该页面。你需要创建一个对应的 Controller 来渲染这个页面。
    • 如果 accessDeniedHandler 根据请求类型(如 Accept: application/json)返回 JSON 错误体和 403 状态码,那它就适合 AJAX 场景。

选择哪种方案取决于你的具体需求和项目架构。方案一更直接地解决了原始问题的场景,改动相对较小。方案二则在架构上更符合 Spring Security 的设计哲学,但可能需要多做一些工作来实现权限表达式或上下文传递。