Spring Security自定义AuthenticationManager实现手机号/密码双认证

📅 2026/7/4 1:41:25 👁️ 阅读次数 📝 编程学习
Spring Security自定义AuthenticationManager实现手机号/密码双认证

01

整体思路 3 步走

  1. 1.自定义认证提供者CustomAuthenticationProvider
    识别登录方式,分发给对应UserDetailsService
  2. 2.双 Service
    UserDetailsService验证账号密码
    PhoneNumberUserService验证手机号验证码
  3. 3.配置注入:把自定义提供者塞进 Spring Security,让它乖乖听话。

02

自定义认证提供者

publicclassCustomAuthenticationProviderimplementsAuthenticationProvider{privatefinalUserDetailsServiceuserDetailsService;// 账号密码验证privatefinalPasswordEncoderpasswordEncoder;// 密码加密器privatefinalPhoneNumberUserServicephoneNumberUserService;// 手机号验证publicCustomAuthenticationProvider(UserDetailsServiceuserDetailsService,PasswordEncoderpasswordEncoder,PhoneNumberUserServicephoneNumberUserService){this.userDetailsService=userDetailsService;this.passwordEncoder=passwordEncoder;this.phoneNumberUserService=phoneNumberUserService;}@OverridepublicAuthenticationauthenticate(Authenticationauthentication)throwsAuthenticationException{Stringprincipal=(String)authentication.getPrincipal();// username:xxx 或 phone:xxxStringcredentials=(String)authentication.getCredentials();// 密码或验证码UserDetailsuserDetails;if(principal.startsWith("username:")){// 账号密码登录Stringusername=principal.substring("username:".length());userDetails=userDetailsService.loadUserByUsername(username);if(!passwordEncoder.matches(credentials,userDetails.getPassword())){thrownewBadCredentialsException("密码错误");}}elseif(principal.startsWith("phone:")){// 手机号登录StringphoneNumber=principal.substring("phone:".length());userDetails=phoneNumberUserService.loadUserByPhoneNumber(phoneNumber);// 这里验证码校验可放在 service 内,也可前置过滤器else{thrownewBadCredentialsException("登录方式不支持");}// 生成已认证令牌UsernamePasswordAuthenticationTokenresult=newUsernamePasswordAuthenticationToken(userDetails,credentials,userDetails.getAuthorities());result.setDetails(authentication.getDetails());returnresult;}@Overridepublicbooleansupports(Class<?>authentication){returnUsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);}}}

注解:

  1. 1.前缀识别:用username:phone:做路由,避免写两套接口。

  2. 2.职责分离:验证码校验交给PhoneNumberUserService,保持单一职责。

  3. 3.线程安全:所有依赖通过构造器注入,无共享可变状态,天然并发友好。

03

双 Service 实现

UserDetailsService(账号密码版)

@Service@RequiredArgsConstructorpublicclassUserDetailsServiceImplimplementsUserDetailsService{privatefinalUserMapperuserMapper;privatefinalMenuMappermenuMapper;@OverridepublicUserDetailsloadUserByUsername(Stringusername)throwsUsernameNotFoundException{Useruser=userMapper.selectOne(newLambdaQueryWrapper<User>().eq(User::getUserName,username));if(user==null)thrownewUsernameNotFoundException("用户不存在");List<String>perms=menuMapper.selectPermsByUserId(user.getId());perms.add(user.getRoles());// 合并角色returnnewLoginUser(user,perms);}}

PhoneNumberUserService(手机号验证码版)

@Service@RequiredArgsConstructorpublicclassPhoneNumberUserService{privatefinalUserMapperuserMapper;privatefinalMenuMappermenuMapper;privatefinalRedisTemplate<String,String>redisTemplate;// 缓存验证码publicUserDetailsloadUserByPhoneNumber(StringphoneNumber){// 1️ 查库Useruser=userMapper.selectOne(newLambdaQueryWrapper<User>().eq(User::getPhonenumber,phoneNumber));if(user==null)thrownewRuntimeException("手机号未注册");// 2️ 查权限List<String>perms=menuMapper.selectPermsByUserId(user.getId());perms.add(user.getRoles());// 3️验证码校验示例(可前置过滤器)//String codeInRedis = redisTemplate.opsForValue().get("SMS:" + phoneNumber);returnnewLoginUser(user,perms);}}

注解:

  1. 1.LambdaQueryWrapper:MyBatis-Plus 写法,链式清爽。

  2. 2.角色权限合并:把角色当权限塞到同一集合,后续授权更丝滑。

  3. 3.验证码解耦:校验逻辑可放在 Service,也可前置过滤器,灵活插拔。

04

SecurityConfig:把自定义提供者塞进去

@Configuration@EnableWebSecurity@RequiredArgsConstructorpublicclassSecurityConfig{privatefinalAuthenticationConfigurationauthenticationConfiguration;//密码加密器@BeanpublicPasswordEncoderpasswordEncoder(){returnnewBCryptPasswordEncoder();}@BeanpublicUserDetailsServiceuserDetailsService(){returnnewUserDetailsServiceImpl();}@BeanpublicPhoneNumberUserServicephoneNumberUserService(){returnnewPhoneNumberUserService();}@BeanpublicCustomAuthenticationProvidercustomAuthenticationProvider(){returnnewCustomAuthenticationProvider(userDetailsService(),passwordEncoder(),phoneNumberUserService());}@BeanpublicAuthenticationManagerauthenticationManager()throwsException{// 替换默认 AuthenticationManagerreturnnewProviderManager(customAuthenticationProvider());}}

注解:

  1. 1.ProviderManager:Spring Security 的核心调度器,塞入我们的 Provider 就能接管认证。

  2. 2.构造器注入:Spring 推荐写法,避免循环依赖。

  3. 3.无 @Autowired:全部显式 Bean,方便单测 Mock。

05

登录接口:一行代码双通道

@RestController@RequestMapping("/auth")@RequiredArgsConstructorpublicclassAuthController{privatefinalAuthenticationManagerauthenticationManager;privatefinalRedisTemplate<String,Object>redisTemplate;@PostMapping("/login")publicResultlogin(@RequestBodyLoginDTOdto){Stringprincipal=dto.getLoginType()==1?"username:"+dto.getUsername():"phone:"+dto.getPhone();UsernamePasswordAuthenticationTokentoken=newUsernamePasswordAuthenticationToken(principal,dto.getCredential());Authenticationauthenticate=authenticationManager.authenticate(token);LoginUserloginUser=(LoginUser)authenticate.getPrincipal();Stringjwt=JwtUtil.createJWT(loginUser.getUser().getId().toString());redisTemplate.opsForValue().set("login:"+loginUser.getUser().getId(),loginUser);returnResult.OK("登录成功",Map.of("token",jwt));}}

注解:

  1. 1.DTO 统一:前端传loginType=1账号密码,2手机号验证码,后端零 if-else。

  2. 2.JWT + Redis:无状态 Token + 在线用户信息缓存,分布式登录稳稳的。

  3. 3.异常透传:认证失败直接抛异常,被全局异常处理器统一包装,前端拿到统一格式。

测试

登录方式请求体返回
账号密码{"loginType":1,"username":"yuqn","credential":"123456"}{"msg":"登录成功","token":"eyJ..."}
手机验证码{"loginType":2,"phone":"13800138000","credential":"8888"}同上