准备
需求
采用手机号+短信验证码登录方式是很常见的一种需求。
那我们如何在Spring Security实现这种功能呢?
表单登录流程
首先再回顾一下用户名密码表单登录流程。
- 登录请求进入过滤器
- 调用认证管理器
- 查询数据库中的信息,返回UserDetails
- AuthenticationProvider校验请求和数据库中的密码是否配置
- 封装已认证信息
实现思路
我们可以完全按照表单登录,自定义过滤器、校验器等,实现短信登录功能。
案例演示
1. 获取验证码
登录页中,用户填写手机号后,会有一个获取验证码的按钮。
我们编写一个获取验证码的接口,模拟短信平台发送验证码,然后将验证码放在缓存中。
@RestController
@RequestMapping("/sms")
@Slf4j
public class SmsEndpoint {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@GetMapping("/send/code")
public Map<String,String> msmCode(String phone) {
log.info(phone + "请求获取验证码");
String code = RandomUtil.randomNumbers(6);
redisTemplate.opsForValue().setIfAbsent(phone, code, 60, TimeUnit.SECONDS);
String saveCode = redisTemplate.opsForValue().get(phone);
Long expire = redisTemplate.opsForValue().getOperations().getExpire(phone, TimeUnit.SECONDS);
Map<String,String> result=new HashMap<>();
result.put("code",saveCode);
result.put("过期时间",expire+"秒");
return result;
}
}
在WebSecurityConfigurerAdapter中放行验证码接口。
http.authorizeRequests()
.antMatchers("/login", "/sms/send/code","/sms/login").permitAll()
接口测试通过:
2. 编写AuthenticationToken类
参考UsernamePasswordAuthenticationToken编写一个AuthenticationToken类,这个类主要用于认证时,封装认证信息
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 1L;
private final Object principal;
private Object credentials;
public SmsAuthenticationToken(Object principal, Object credentials) {
super((Collection) null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false);
}
public SmsAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
3. 编写过滤器
参考UsernamePasswordAuthenticationFilter写一个过滤器,拦截短信登录接口/sms/login,调用认证管理器进行认证。
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/sms/login", "POST");
private String phoneParameter = "phone";
private String smsCodeParameter = "smsCode";
private boolean postOnly = true;
public SmsAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public SmsAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String phone = this.obtainPhone(request);
phone = phone != null ? phone : "";
phone = phone.trim();
String smsCode = this.obtainSmsCode(request);
smsCode = smsCode != null ? smsCode : "";
SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phone, smsCode);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
@Nullable
protected String obtainSmsCode(HttpServletRequest request) {
return request.getParameter(this.smsCodeParameter);
}
@Nullable
protected String obtainPhone(HttpServletRequest request) {
return request.getParameter(this.phoneParameter);
}
protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setPhoneParameter(String phoneParameter) {
Assert.hasText(phoneParameter, "Phone parameter must not be empty or null");
this.phoneParameter = phoneParameter;
}
public void setSmsCodeParameter(String smsCodeParameter) {
Assert.hasText(smsCodeParameter, "SmsCode parameter must not be empty or null");
this.smsCodeParameter = smsCodeParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return this.phoneParameter;
}
public final String getPasswordParameter() {
return this.smsCodeParameter;
}
}
4. 编写UserDetailsService
UserDetailsService主要负责根据用户名查询数据库信息,这里我们需要通过手机号查询用户信息。
@Service("smsUserDetailsService")
public class SmsUserDetailsService implements UserDetailsService {
@Autowired
UserService userService;
@Override
public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
UserInfoDO userInfoDO = userService.getUserInfoByName("test");
if (userInfoDO == null) {
throw new UsernameNotFoundException("手机号不存在!");
}
List<Role> roleList = userInfoDO.getRoleList();
List<Permission> permissionList = userInfoDO.getPermissionList();
List<GrantedAuthority> authorityList = new ArrayList<>();
roleList.forEach(e -> {
String roleCode = e.getRoleCode();
if (StringUtils.isNotBlank(roleCode)) {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(roleCode);
authorityList.add(simpleGrantedAuthority);
}
});
permissionList.forEach(e -> {
String code = e.getCode();
if (StringUtils.isNotBlank(code)) {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code);
authorityList.add(simpleGrantedAuthority);
}
});
MyUser myUser = new MyUser(userInfoDO.getUserName(), userInfoDO.getPassword(), authorityList);
myUser.setDeptId(userInfoDO.getOrganizationId());
myUser.setLoginTime(new Date());
return myUser;
}
}
5. 编写AuthenticationProvider
认证管理器会调用认证程序AuthenticationProvider进行认证操作,我们需要实现根据用户输入参数,校验验证码,匹配后,设置认证成功。
@Component
public class SmsAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsServiceImpl;
private RedisTemplate<String, String> redisTemplate;
public SmsAuthenticationProvider(@Qualifier("smsUserDetailsService") UserDetailsService userDetailsServiceImpl, RedisTemplate<String, String> redisTemplate) {
this.userDetailsServiceImpl = userDetailsServiceImpl;
this.redisTemplate = redisTemplate;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
Object principal = authentication.getPrincipal();
String phone = "";
if (principal instanceof String) {
phone = (String) principal;
}
String inputCode = (String) authentication.getCredentials();
String redisCode = redisTemplate.opsForValue().get(phone);
if (StrUtil.isEmpty(redisCode)) {
throw new BadCredentialsException("验证码已经过期或尚未发送,请重新发送验证码");
}
if (!inputCode.equals(redisCode)) {
throw new BadCredentialsException("输入的验证码不正确,请重新输入");
}
UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(phone);
if (userDetails == null) {
throw new InternalAuthenticationServiceException("phone用户不存在,请注册");
}
SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(principal,inputCode, userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> aClass) {
return SmsAuthenticationToken.class.isAssignableFrom(aClass);
}
}
6. 编写短信认证配置类
编写短信认证配置类,将过滤器,认证程序等加入到配置中。
@Configuration
public class SmsSecurityConfigurerAdapter extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
CustomAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private RedisTemplate<String,String> redisTemplate;
@Autowired
CustomAuthenticationFailureHandler authenticationFailureHandler;
@Resource
private SmsUserDetailsService smsUserDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider(smsUserDetailsService,redisTemplate);
http.authenticationProvider(smsAuthenticationProvider)
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
将以上配置添加到主配置中,并需要在AuthenticationManager中添加DaoAuthenticationProvider,不然用户名密码登录会报错。
@Configuration
@EnableWebSecurity(debug = true)
public class MyWebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
MyUserDetailsService myUserDetailsService;
@Autowired
CustomAuthenticationFailureHandler authenticationFailureHandler;
@Autowired
CustomAccessDeniedHandler accessDeniedHandler;
@Autowired
CustomAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private SmsSecurityConfigurerAdapter smsSecurityConfigurerAdapter;
@Autowired
SmsAuthenticationProvider smsAuthenticationProvider;
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().cors();
http.authorizeRequests()
.antMatchers("/login", "/sms/send/code", "/sms/login").permitAll()
.anyRequest()
.authenticated()
.and()
.formLogin()
.failureHandler(authenticationFailureHandler)
.successHandler(authenticationSuccessHandler);
http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
http.apply(smsSecurityConfigurerAdapter);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
super.configure(auth);
}
@Override
protected AuthenticationManager authenticationManager() throws Exception {
ProviderManager authenticationManager = new ProviderManager(Arrays.asList(smsAuthenticationProvider, daoAuthenticationProvider()));
authenticationManager.setEraseCredentialsAfterAuthentication(false);
return authenticationManager;
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
daoAuthenticationProvider.setUserDetailsService(myUserDetailsService);
daoAuthenticationProvider.setHideUserNotFoundExceptions(false);
return daoAuthenticationProvider;
}
}
7. 测试
首先获取短信验证码: 然后调用短信登录接口,登录成功并返回了认证信息。
然后访问另一个资源接口,发现成访问到了。 使用用户名密码也可以登录:
|