前言
之前的博客有写过SpringCloud+SpringSecurity+Oauth2的token刷新功能,最近又完成了短信验证码登录的功能。SpringSecurityOauth2没有直接提供集成短信登录的配置,我们可以仿照用户名密码登录的整个流程来实现短信验证码的登录。主要参考来自视频https://www.bilibili.com/video/BV16J41127jq?p=24,大家有时间的话还是可以将所有的视频看一下,照着他的做基本就可以实现短信登录的需求。
一、基本思路
首先强调一点,SringSecurity中的一个重点就是过滤器链,那么很多我们自己想实现的需求可以考虑在它自有的过滤器链中加入自己的过滤器。
首先我们来看一下用户名密码登录过滤器流程:
首先我们的用户名密码登录后会被认证过滤器拦截,用户名密码这些认证信息会被包装到一个UsernamePasswordAuthenticationToken中,那么再经过Manager和Provider,在provider中就会调用我们自己实现的方式loadUserByUsername,在provider中会对密码进行校验。认证通过后,会继续发出之前的请求,例如我们在单点登录时,其实是直接请求的获取code的路径,那么再没有登录的情况下会跳转到登录页,登录成功会继续发出获取code的请求。
其实做短信登录最主要的就是我们输入手机号和验证码(获取验证码的方法这里就不在赘述,跟其它一样,没有什么特别需要注意的,就是得在配置文件中允许请求)后怎么能够知道这个请求是验证码登录?在哪里去验证?怎么最后结合到loadUserByUsername方法?那么随后的流程就可以汇合到一块儿,不用再单独处理了。
主要模拟左边的流程来实现短信登录,右边的两个类可以将左边类中的内容copy过来做修改。
二、代码实现
1.需要的类
2.具体代码
/**
* 验证码校验过滤器
* 2021年10月21日15:07:19
*/
@Component
public class SmsCodeValidateFilter extends OncePerRequestFilter {
@Resource
private RedisTemplate redisTemplate;
@Autowired
private UserService userService;
@Autowired
private SmsAuthenticationFailureHandler smsAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
if(StringUtils.equals("/loginBySms",httpServletRequest.getRequestURI()) && StringUtils.equalsIgnoreCase(httpServletRequest.getMethod(),"post")){
//验证手机号、验证码等信息,同样需要对输入的手机号进行查库校验是否是注册的手机号
try {
String mobile = httpServletRequest.getParameter("mobile");
String codeRequest = httpServletRequest.getParameter("code");
String codeRedis = redisTemplate.opsForValue().get(mobile) == null ? "" : String.valueOf(redisTemplate.opsForValue().get(mobile));
SysUser sysUser = userService.getUserByMobile(mobile);
if(sysUser == null){
throw new SessionAuthenticationException("该手机号不是系统注册用户");
}
if(StringUtils.isEmpty(mobile)){
throw new SessionAuthenticationException("手机号不能为空");
}
if(StringUtils.isEmpty(codeRequest)){
throw new SessionAuthenticationException("短信验证码不能为空");
}
if(StringUtils.isEmpty(codeRedis)){
throw new SessionAuthenticationException("短信验证码已过期");
}
if(!codeRequest.equals(codeRedis)){
throw new SessionAuthenticationException("短信验证码不正确");
}
} catch (SessionAuthenticationException e) {
smsAuthenticationFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
return;
}
}
//验证成功后继续后续的过滤器链
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
/**
* 用来拦截加载用户详细信息的过滤器
* 2021年10月21日15:09:00
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = "mobile";
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher("/loginBySms", "POST"));
}
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String mobile = this.obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(this.mobileParameter);
}
protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getMobileParameter() {
return this.mobileParameter;
}
}
那么在上边的过滤器中还需要一个token类来保存手机号认证信息
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 511L;
//存放认证信息,认证之前是手机号,认证之后UserDetails
private final Object principal;
public SmsCodeAuthenticationToken(Object principal) {
super((Collection)null);
this.principal = principal;
this.setAuthenticated(false);
}
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
public Object getPrincipal() {
return this.principal;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
} else {
super.setAuthenticated(false);
}
}
public void eraseCredentials() {
super.eraseCredentials();
}
provider:
/**
* 主要是用来配置userDetailsService类来查询用户信息
* 跟之前的用户名密码登录汇合到一起
* 2021年10月21日15:10:20
*/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private Logger logger = LoggerFactory.getLogger(SmsCodeAuthenticationProvider.class);
private UserDetailsService userDetailsService;
private UserDetailsChecker preAuthenticationChecks = new SmsCodeAuthenticationProvider.DefaultPreAuthenticationChecks();
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
protected UserDetailsService getUserDetailsService() {
return this.userDetailsService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken)authentication;
UserDetails userDetails = null;
try {
userDetails = userDetailsService.loadUserByUsername((String)authenticationToken.getPrincipal());
} catch (EmailException e) {
//这两个异常是根据自己的需要自己加的。在loadUserByUsername方法中会校验邮箱有没有经过验证,ip有没有被锁定等,可以抛出异常,在这人捕获,然后交由登录失败处理器去处理,给出前端对应的提示
//将短信登录后查询的用户详细信息涉及到的用户不能登录的异常交由短信登录失败处理器去处理
throw new AuthenticationServiceException("Email is not verified");
}catch (IpLockException e1){
throw new AuthenticationServiceException("ip is locked");
}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails,new ArrayList<SimpleGrantedAuthority>());
//authenticationResult.setDetails(authenticationToken.getDetails());
preAuthenticationChecks.check(userDetails);
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
/**
* 模拟DaoAuthenticationProvider
* 用来检验账号是否被禁用、被锁
* 直接粘过来就能用
*/
private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
private DefaultPreAuthenticationChecks() {
}
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
logger.debug("User account is locked");
throw new LockedException("User account is locked");
} else if (!user.isEnabled()) {
logger.debug("User account is disabled");
throw new DisabledException("User is disabled");
} else if (!user.isAccountNonExpired()) {
logger.debug("User account is expired");
throw new AccountExpiredException("User account has expired");
}
}
}
}
成功处理器:
@Component
public class SmsAuthenticationSuccessHandle extends SimpleUrlAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
try {
MyUser myUser = (MyUser) authentication.getPrincipal();
try {
//可以加登录成功后记录日志,代码省略
} catch (Exception e) {
logger.error("记录登录日志出错",e);
}
} catch (Exception e) {
logger.error("登录成功后,修改用户登录错误信息出错",e);
}
//登录成功后继续访问之前的地址,沿用之前接口的逻辑
//源码的代码,直接沿用。登录成功后,从缓冲中取出跳转到登录页面之前的访问地址继续访问
SavedRequest savedRequest = this.requestCache.getRequest(request, response);
if (savedRequest == null) {
super.onAuthenticationSuccess(request, response, authentication);
} else {
String targetUrlParameter = this.getTargetUrlParameter();
if (!this.isAlwaysUseDefaultTargetUrl() && (targetUrlParameter == null || !StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
this.clearAuthenticationAttributes(request);
String targetUrl = savedRequest.getRedirectUrl();
this.logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
this.getRedirectStrategy().sendRedirect(request, response, targetUrl);
} else {
this.requestCache.removeRequest(request, response);
super.onAuthenticationSuccess(request, response, authentication);
}
}
}
}
失败处理器:
/**
* 短信验证错误处理类
* 2021年10月21日16:51:27
*/
@Component
public class SmsAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws ServletException {
String message = e.getMessage();
try {
// httpServletResponse.setCharacterEncoding("UTF-8");
// httpServletResponse.getWriter().write(message);
// httpServletResponse.sendRedirect("/login");
//短信登录验证不正确,转发到登录请求post,跳转到登录页面
//重定向的话因为不是同一个request域,提示信息没法携带过去
httpServletRequest.setAttribute("codeMessage",message);
httpServletRequest.getRequestDispatcher("/login").forward(httpServletRequest,httpServletResponse);
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
配置类:
/**
* 短信登录配置类
* 2021年10月21日14:25:39
*/
@Component
public class SmsCodeSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private MyUserDetailsServiceImpl userDetailsService;
@Autowired
private SmsCodeValidateFilter smsCodeValidateFilter;
@Autowired
private SmsAuthenticationFailureHandler smsAuthenticationFailureHandler;
@Autowired
private SmsAuthenticationSuccessHandle smsAuthenticationSuccessHandle;
@Override
public void configure(HttpSecurity http) throws Exception {
//1、配置过滤器
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
//成功和失败的处理器,需要自己处理
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(smsAuthenticationSuccessHandle);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(smsAuthenticationFailureHandler);
//2、配置provider
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
//3、确定各个过滤器的顺序,验证码的验证过滤器应该在加载用户详细信息之前
http.authenticationProvider(smsCodeAuthenticationProvider);
/*http.addFilterBefore(smsCodeValidateFilter,UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);*/
http.addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
}
}
因为之前没有集成短信登录的时候也有一个配置类,那么需要将短息配置类加入到那个主的配置类中:
总结
1、短信登录只是一个认证过程,它不会应该oauth2的单点登录过程,不需要特殊处理;
2、单点登录的本质是session共享,或者说是同一个域下可以维持同一个会话,这里边涉及到cookie和session的知识。cookie是和域关联的,oauth2之所以能做单点登录是因为认证服务器是唯一的,在登录认证获取code(授权码模式)后,浏览器会保存与认证服务器的会话,当我们请求另外一个集成到单点登录中的服务时,获取code时会去请求认证服务器,因为认证服务器是同一个,那么之前浏览器保存的会话(sessionid)会被携带过去,认证服务器检验已经登录过了,所以不用再次登录直接颁发code,所以才会实现单点登录;
3、对于自己刚刚涉及到的技术,虽然百度一下或者看看别人的代码能够实现,但是还是要多看,多系统的看,每看一次都感觉理解的会深入一点儿