Spring Security自定义AuthenticationManager实现手机号/密码双认证
01
整体思路 3 步走
- 1.自定义认证提供者
CustomAuthenticationProvider:
识别登录方式,分发给对应UserDetailsService。 - 2.双 Service:
•UserDetailsService验证账号密码
•PhoneNumberUserService验证手机号验证码 - 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.前缀识别:用
username:和phone:做路由,避免写两套接口。2.职责分离:验证码校验交给
PhoneNumberUserService,保持单一职责。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.LambdaQueryWrapper:MyBatis-Plus 写法,链式清爽。
2.角色权限合并:把角色当权限塞到同一集合,后续授权更丝滑。
3.验证码解耦:校验逻辑可放在 Service,也可前置过滤器,灵活插拔。
04
SecurityConfig:把自定义提供者塞进去
@Configuration@EnableWebSecurity@RequiredArgsConstructorpublicclassSecurityConfig{privatefinalAuthenticationConfigurationauthenticationConfiguration;//密码加密器@BeanpublicPasswordEncoderpasswordEncoder(){returnnewBCryptPasswordEncoder();}@BeanpublicUserDetailsServiceuserDetailsService(){returnnewUserDetailsServiceImpl();}@BeanpublicPhoneNumberUserServicephoneNumberUserService(){returnnewPhoneNumberUserService();}@BeanpublicCustomAuthenticationProvidercustomAuthenticationProvider(){returnnewCustomAuthenticationProvider(userDetailsService(),passwordEncoder(),phoneNumberUserService());}@BeanpublicAuthenticationManagerauthenticationManager()throwsException{// 替换默认 AuthenticationManagerreturnnewProviderManager(customAuthenticationProvider());}}注解:
1.ProviderManager:Spring Security 的核心调度器,塞入我们的 Provider 就能接管认证。
2.构造器注入:Spring 推荐写法,避免循环依赖。
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.DTO 统一:前端传
loginType=1账号密码,2手机号验证码,后端零 if-else。2.JWT + Redis:无状态 Token + 在线用户信息缓存,分布式登录稳稳的。
3.异常透传:认证失败直接抛异常,被全局异常处理器统一包装,前端拿到统一格式。
测试
| 登录方式 | 请求体 | 返回 |
|---|---|---|
| 账号密码 | {"loginType":1,"username":"yuqn","credential":"123456"} | {"msg":"登录成功","token":"eyJ..."} |
| 手机验证码 | {"loginType":2,"phone":"13800138000","credential":"8888"} | 同上 |
