SpringSecurity自定义多Provider时提示No AuthenticationProvider found for问题的解决方案与原理(一)

文章会比较长,这个问题困扰了我接近一周的时间,前前后后搜过无数的资料文档,几乎翻遍了Security的源码部分,这四五天的时间可以说Security那迷宫一样的初始化机制就是我挥之不去的梦魇,所以我想从问题的发现开始,记录下我排查问题的每一步,希望能帮到以后的人。

为什么这么感慨呢,我相信,当你认认真真看完整篇文章之后,会发现一个很惊人的事实:网上关于SpringSecurity的使用方法99%都是错误的,我尝试过的几个高分开源项目(例如ruoyi)关于SpringSecurity的使用也完全是错误的。 为什么没有被发现呢,因为只是用来做简单系统单登录方式的话根本不会用到这一块功能,如果要扩展多用户体系多登陆模式,网上的方法虽然行得通,但却极大违背了SpringSecurity的设计初衷,是根本思想上的错误。

注意本文使用的是不继承WebSecurityConfigurerAdapter的方式,因为此方法在5.7版本开始就被废弃了,虽然实现方式不同但是运行机制是一样的。

起因

介绍一下版本情况

SpringBoot 2.6.9
SpringSecurity 5.6.6

最近项目上有个需求,需要支持用户名密码模式、手机验证码模式和OAuth授权码模式三种登录,项目本身采用SpringSecurity作为安全框架,之前的模式和网上众多教程别无二致,无非是/login接口作为白名单放行,业务侧颁发jwt token。新需求一来就有点不够看了,要针对多种方式做单独的登录处理,都挤在一个业务处理类中实在是有点太邋遢,于是自然而言就想到了利用SpringSecurityAuthenticationProvider来帮我们实现这一功能。


运行的大体流程是这样的:

  1. 构建一个特定的Token类,例如这里的PasswordAuthenticationToken,这个类需要继承AbstractAuthenticationToken,在你需要做认证的地方把他new出来;
  2. 把上面new出来的对象传递给authenticationManager.authenticate()方法,他会根据AuthenticationManager中维护的AuthenticationProvider列表逐个调用其supports()方法,若提供的token类与当前AuthenticationProvider所匹配则交由该provider执行;
  3. authenticate()方法若认证成功,则返回一个完全构建好的Authentication对象告知Security认证已完成,不需要再往下走认证器链了,若认证不成功返回null或抛出相应的异常(注意异常父类必须是AuthenticationException),Security会继续向下寻找Provider直至走完整个过滤器链,返回认证失败。

看起来很简单是不是,三下五除二改造完成,先看一下此时的项目目录结构
在这里插入图片描述

为方便理解这里以带图形验证码的用户名密码登录为例贴一下相关代码(已隐去import部分),核心逻辑都加了注释,注意阅读。

/**
 * 基于用户名(手机号)、密码、验证码登录的认证实体
 */
public class PasswordAuthenticationToken extends AbstractAuthenticationToken {

    private final String loginId;
    private final String captchaId;
    private final String captchaValue;
    private final LoginUserPojo principal;
    private final String credentials;

    /**
     * 登录验证
     *
     * @param loginId      用户名或手机号
     * @param credentials  MD5+SM3密码
     * @param captchaId    图形验证码id
     * @param captchaValue 输入的图形验证码值
     */
    public PasswordAuthenticationToken(String loginId, String credentials, String captchaId, String captchaValue) {
        super(null);
        this.loginId = loginId;
        this.credentials = credentials;
        this.captchaId = captchaId;
        this.captchaValue = captchaValue;
        this.principal = null;
        this.setAuthenticated(false);
    }

    /**
     * 授权信息
     *
     * @param principal   LoginUserPojo
     * @param credentials token
     * @param authorities 角色清单
     */
    public PasswordAuthenticationToken(LoginUserPojo principal, String credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        this.loginId = null;
        this.captchaId = null;
        this.captchaValue = null;
        this.setAuthenticated(true);
    }

    public String getLoginId() {
        return loginId;
    }

    public String getCaptchaId() {
        return captchaId;
    }

    public String getCaptchaValue() {
        return captchaValue;
    }

    @Override
    public LoginUserPojo getPrincipal() {
        return principal;
    }

    @Override
    public String getCredentials() {
        return credentials;
    }
}
/**
 * 基于用户名(手机号)、密码、验证码的认证处理器
 */
@Component
public class PasswordAuthenticationProvider implements AuthenticationProvider {

    private static final String IMG_CAPTCHA_REDIS_PREFIX = "SK:CAPTCHA:IMG:";

    @Autowired
    private UserDetailServiceImpl userDetailService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private RedisCacheUtil redisCacheUtil;

    /**
     * 验证主逻辑
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        PasswordAuthenticationToken authenticationToken = (PasswordAuthenticationToken) authentication;

        // 验证码校验
        if (!checkImgCaptcha(authenticationToken.getCaptchaId(), authenticationToken.getCaptchaValue())) {
            throw new BadCaptchaException("验证码有误或已过期,请重新输入");
        }

        // 密码校验
        LoginUserPojo userDetails = (LoginUserPojo) userDetailService.loadUserByUsername(authenticationToken.getLoginId());
        if (!passwordEncoder.matches(authenticationToken.getCredentials(), userDetails.getPassword())) {
            throw new BadCredentialsException("用户名或密码错误,请重新输入");
        }

        // 用户状态校验
        if (!userDetails.isEnabled() || !userDetails.isAccountNonLocked() || !userDetails.isAccountNonExpired()) {
            throw new LockedException("用户已禁用,请联系管理员启用");
        }

        return new PasswordAuthenticationToken(userDetails, authenticationToken.getCredentials(), userDetails.getAuthorities());
    }

    /**
     * 当类型为PasswordAuthenticationToken的认证实体进入时才走此Provider
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return PasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }

    /**
     * 校验验证码正确与否,验证完成后删除当前码值
     *
     * @param id    验证码对应的id
     * @param value 用户输入的验证码结果
     * @return true or false
     */
    private boolean checkImgCaptcha(String id, String value) {
        if (StringUtils.isBlank(id) || StringUtils.isBlank(value)) {
            return false;
        }

        CaptchaCodePojo captchaCode = redisCacheUtil.getObject(IMG_CAPTCHA_REDIS_PREFIX + id);
        redisCacheUtil.deleteObject(IMG_CAPTCHA_REDIS_PREFIX + id);

        return !Objects.isNull(captchaCode) && value.equals(captchaCode.getResult());
    }
}

以下是登录Service

/**
 * 登录
 */
@Service
public class LoginServiceImpl implements ILoginService {

    private static final String IMG_CAPTCHA_REDIS_PREFIX = "SK:CAPTCHA:IMG:";

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCacheUtil redisCacheUtil;

    @Autowired
    private SysUserMapper userMapper;

    @Override
    public String login(Map<String, String> params) {
        // 实际业务执行在PasswordAuthenticationProvider中
        Authentication authentication = new PasswordAuthenticationToken(
                params.get("loginKey"), params.get("password"), params.get("id"), params.get("value")
        );
        Authentication authenticate = authenticationManager.authenticate(authentication);
        LoginUserPojo loginUserPojo = (LoginUserPojo) authenticate.getPrincipal();

        // 更新登录时间
        updateLoginTime(loginUserPojo.getUserId());
        return buildToken(loginUserPojo);
    }

    @Override
    public String oAuthLogin(String code) {
        // 实际业务执行在OAuthAuthenticationProvider中
        Authentication authentication = new OAuthAuthenticationToken(code);
        Authentication authenticate = authenticationManager.authenticate(authentication);
        LoginUserPojo loginUserPojo = (LoginUserPojo) authenticate.getPrincipal();

        // 更新登录时间
        updateLoginTime(loginUserPojo.getUserId());
        return buildToken(loginUserPojo);
    }

    /**
     * 根据用户信息构造token并写入redis
     *
     * @param loginUserPojo LoginUserPojo
     * @return token
     */
    private String buildToken(LoginUserPojo loginUserPojo) {
        JSONObject user = new JSONObject();
        user.put("userId", loginUserPojo.getUserId());
        user.put("userName", loginUserPojo.getUserName());
        user.put("roleCode", loginUserPojo.getAuthorities().stream().map(UserGrantedAuthority::getRoleCode).collect(Collectors.joining(",")));

        // 生成token
        String token = JwtTokenUtil.createJwtToken(user);
        redisCacheUtil.setObject(TokenConstant.TOKEN_REDIS_PREFIX + token, loginUserPojo, TokenConstant.TOKEN_EXPIRE_TIME, TokenConstant.TOKEN_EXPIRE_TIME_UNIT);
        return token;
    }

    @Override
    public Map<String, String> generateImageCaptcha() throws IOException {
        CaptchaCodePojo captchaCode = new MathCaptchaGenerator(1).generate();
        SimpleCaptchaRender captchaRender = new SimpleCaptchaRender(90, 30, captchaCode.getCode(), 2, new Font(Font.SANS_SERIF, Font.BOLD, (int) (30 * 0.75)));

        Map<String, String> result = new HashMap<>(2);
        result.put("id", UUID.randomUUID().toString());
        result.put("pic", captchaRender.getImageBase64());

        // 将生成的验证码及结果存入redis,有效期两分钟
        redisCacheUtil.setObject(IMG_CAPTCHA_REDIS_PREFIX + result.get("id"), captchaCode, 2, TimeUnit.MINUTES);
        return result;
    }

    /**
     * 更新登陆时间
     *
     * @param userId 用户id
     */
    private void updateLoginTime(String userId) {
        SysUserEntity userEntity = new SysUserEntity();
        userEntity.setUserId(userId);
        userEntity.setLastLogin(DateTimeUtil.getCurrentDate("yyyy-MM-dd HH:mm:ss"));
        userMapper.updateByUserId(userEntity);
    }
}

补充一个用来构造相关Bean的类

/**
 * SpringSecurity相关Bean构造
 */
@Component
public class SpringSecurityBeans {
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

以及核心的SpringSecurity Config

/**
 * SpringSecurity配置类
 */
@EnableWebSecurity
@PropertySource("classpath:authfilter.properties")
public class SpringSecurityConfig {

    @Value("${exclude_urls}")
    private String excludeUrls;

    /**
     * token认证过滤器
     */
    @Autowired
    private AuthenticationTokenFilter authenticationTokenFilter;

    /**
     * 认证失败处理器
     */
    @Autowired
    private AuthenticationFailHandler authenticationFailHandler;

    /**
     * 注销处理器
     */
    @Autowired
    private AuthenticationLogoutHandler logoutHandler;

    /**
     * 密码认证处理器
     */
    @Autowired
    private PasswordAuthenticationProvider passwordAuthenticationProvider;
    
    /**
     * OAuth认证处理器
     */
    @Autowired
    private OAuthAuthenticationProvider oAuthAuthenticationProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf().disable()
                .formLogin().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests()
                .antMatchers(StringUtils.split(excludeUrls, ",")).permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling().authenticationEntryPoint(authenticationFailHandler)
                .and()
                .logout().logoutUrl("/logout").logoutSuccessHandler(logoutHandler)
                .and()
                // 将自定义的Provider添加到Security中
                .authenticationProvider(passwordAuthenticationProvider)
                .authenticationProvider(oAuthAuthenticationProvider)
                .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

看起来很简单,代码也很简单,几个小时就改好了,编译启动都正常,出于迷之自信也没有做测试直接丢到开发环境,启动运行,本以为今天就要愉快的结束了,然后结结实实的一盆冷水拍到脸上。
在这里插入图片描述

我明明已经把Provider注入到SecurityFilterChain链中了,为什么他会告诉我找不到?此时是8月23日七点半,此时我的噩梦算是正式开始了。
在这里插入图片描述

  • 12
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 18
    评论
这个错误通常是由于Spring Security配置不正确引起的。要解决这个问题,你可以按照以下步骤进行操作: 1. 确保你的Spring Security配置文件正确。检查是否正确引入了Spring Security依赖,并在配置文件中配置了正确的命名空间和schema。 2. 确保在配置文件中添加了AuthenticationProvider的Bean定义。你可以使用`DaoAuthenticationProvider`作为默认的AuthenticationProvider。示例配置如下: ```xml <bean id="authenticationManager" class="org.springframework.security.authentication.ProviderManager"> <property name="providers"> <list> <ref bean="daoAuthenticationProvider"/> </list> </property> </bean> <bean id="daoAuthenticationProvider" class="org.springframework.security.authentication.dao.DaoAuthenticationProvider"> <property name="userDetailsService" ref="yourUserDetailsService"/> <!-- 如果需要密码加密,则需配置密码加密器 --> <property name="passwordEncoder" ref="yourPasswordEncoder"/> </bean> <bean id="yourUserDetailsService" class="com.example.YourUserDetailsService"/> <bean id="yourPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/> ``` 3. 确保你的自定义UserDetailsService实现了`org.springframework.security.core.userdetails.UserDetailsService`接口,并正确实现了`loadUserByUsername`方法。 通过检查以上步骤,你应该能够解决这个错误并成功进行身份验证。如果问题仍然存在,请提供更多的相关代码和配置信息,以便更好地帮助你。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值