文章会比较长,这个问题困扰了我接近一周的时间,前前后后搜过无数的资料文档,几乎翻遍了Security的源码部分,这四五天的时间可以说Security那迷宫一样的初始化机制就是我挥之不去的梦魇,所以我想从问题的发现开始,记录下我排查问题的每一步,希望能帮到以后的人。
为什么这么感慨呢,我相信,当你认认真真看完整篇文章之后,会发现一个很惊人的事实:网上关于SpringSecurity的使用方法99%都是错误的,我尝试过的几个高分开源项目(例如ruoyi)关于SpringSecurity的使用也完全是错误的。为什么没有被发现呢,因为只是用来做简单系统单登录方式的话根本不会用到这一块功能,如果要扩展多用户体系
或多登陆模式
,网上的方法虽然行得通,但却极大违背了SpringSecurity
的设计初衷,是根本思想上的错误。
注意本文使用的是不继承WebSecurityConfigurerAdapter
的方式,因为此方法在5.7
版本开始就被废弃了,虽然实现方式不同但是运行机制是一样的。
起因
介绍一下版本情况
SpringBoot 2.6.9
SpringSecurity 5.6.6
最近项目上有个需求,需要支持用户名密码模式、手机验证码模式和OAuth授权码模式三种登录,项目本身采用SpringSecurity作为安全框架,之前的模式和网上众多教程别无二致,无非是/login
接口作为白名单放行,业务侧颁发jwt token
。新需求一来就有点不够看了,要针对多种方式做单独的登录处理,都挤在一个业务处理类中实在是有点太邋遢,于是自然而言就想到了利用SpringSecurity
的AuthenticationProvider
来帮我们实现这一功能。
运行的大体流程是这样的:
构建一个特定的Token类,例如这里的PasswordAuthenticationToken
,这个类需要继承AbstractAuthenticationToken
,在你需要做认证的地方把他new出来;把上面new出来的对象传递给authenticationManager.authenticate()
方法,他会根据AuthenticationManager
中维护的AuthenticationProvider
列表逐个调用其supports()
方法,若提供的token类与当前AuthenticationProvider
所匹配则交由该provider执行;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;}@Overridepublic LoginUserPojo getPrincipal() {return principal;}@Overridepublic String getCredentials() {return credentials;}}
/*** 基于用户名(手机号)、密码、验证码的认证处理器*/@Componentpublic class PasswordAuthenticationProvider implements AuthenticationProvider {private static final String IMG_CAPTCHA_REDIS_PREFIX = "SK:CAPTCHA:IMG:";@Autowiredprivate UserDetailServiceImpl userDetailService;@Autowiredprivate PasswordEncoder passwordEncoder;@Autowiredprivate RedisCacheUtil redisCacheUtil;/*** 验证主逻辑*/@Overridepublic 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*/@Overridepublic 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
/*** 登录*/@Servicepublic class LoginServiceImpl implements ILoginService {private static final String IMG_CAPTCHA_REDIS_PREFIX = "SK:CAPTCHA:IMG:";@Autowiredprivate AuthenticationManager authenticationManager;@Autowiredprivate RedisCacheUtil redisCacheUtil;@Autowiredprivate SysUserMapper userMapper;@Overridepublic 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);}@Overridepublic 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(",")));// 生成tokenString token = JwtTokenUtil.createJwtToken(user);redisCacheUtil.setObject(TokenConstant.TOKEN_REDIS_PREFIX + token, loginUserPojo, TokenConstant.TOKEN_EXPIRE_TIME, TokenConstant.TOKEN_EXPIRE_TIME_UNIT);return token;}@Overridepublic 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构造*/@Componentpublic class SpringSecurityBeans {@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}}
以及核心的SpringSecurity Config
/*** SpringSecurity配置类*/@EnableWebSecurity@PropertySource("classpath:authfilter.properties")public class SpringSecurityConfig {@Value("${exclude_urls}")private String excludeUrls;/*** token认证过滤器*/@Autowiredprivate AuthenticationTokenFilter authenticationTokenFilter;/*** 认证失败处理器*/@Autowiredprivate AuthenticationFailHandler authenticationFailHandler;/*** 注销处理器*/@Autowiredprivate AuthenticationLogoutHandler logoutHandler;/*** 密码认证处理器*/@Autowiredprivate PasswordAuthenticationProvider passwordAuthenticationProvider;/*** OAuth认证处理器*/@Autowiredprivate OAuthAuthenticationProvider oAuthAuthenticationProvider;@Beanpublic 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日七点半,此时我的噩梦算是正式开始了。