Spring Security实现短信登录

文章目录

  • 一、理论说明
    • 1.1 用户名密码登录逻辑
    • 1.2 短信验证码登录逻辑
  • 二、代码实战
    • 2.1 SmsAuthenticationToken
    • 2.2 SmsAuthenticationFilter
    • 2.3 SmsAuthenticationProvider
    • hardcode的方式来模拟手机号查询用户信息
    • 2.4 成功与失败处理逻辑
    • 2.5 SmsCodeAuthenticationSecurityConfig
  • 将上述的逻辑加入到一起,放在BrowserSecurityConfig中去

为了省去与本篇主题无关的代码,短信验证码只是一个模拟。如果你需要具体的实际例子,在下面的源码链接中除了包括每一章的代码外,还包括从头到尾的完整整合代码,方便大家参考学习。

一、理论说明

在开始编码前,先理解下短信验证码的实现流程。如果你能对《Spring Security认证过程》这篇文章有一定的了解的话,那么这篇文章的学习你会轻松许多。

1.1 用户名密码登录逻辑

废话不多说,在上一篇文章中,以标准的用户名密码登录为例,讲解了整个认证流程。大致流程如下:

先进入 UsernamePasswordAuthenticationFilter 中,根据输入的用户名和密码信息,构造出一个暂时没有鉴权的 UsernamePasswordAuthenticationToken,并将 UsernamePasswordAuthenticationToken交给 AuthenticationManager 处理。
AuthenticationManager本身并不做验证处理,他通过 for-each 遍历找到符合当前登录方式的一个 AuthenticationProvider,并交给它进行验证处理,对于用户名密码登录方式,这个 Provider 就是 DaoAuthenticationProvider
在这个 Provider中进行一系列的验证处理,如果验证通过,就会重新构造一个添加了鉴权的 UsernamePasswordAuthenticationToken,并将这个 token 传回到 UsernamePasswordAuthenticationFilter 中。
在该 Filter 的父类 AbstractAuthenticationProcessingFilter 中,会根据上一步验证的结果,跳转到 successHandler或者是failureHandler

Spring Security实现短信登录_第1张图片

1.2 短信验证码登录逻辑

我们可以仿照用户名密码登录的逻辑,来实现短信验证码的登陆逻辑。

用户名密码登录有个 UsernamePasswordAuthenticationFilter,我们搞一个 SmsAuthenticationFilter,代码粘过来改一改。
用户名密码登录需要 UsernamePasswordAuthenticationToken,我们搞一个 SmsAuthenticationToken,代码粘过来改一改。
用户名密码登录需要 DaoAuthenticationProvider,我们模仿它也 implenments AuthenticationProvider,叫做 SmsAuthenticationProvider
Spring Security实现短信登录_第2张图片
我们自己搞了上面三个类以后,想要实现的效果如上图所示。当我们使用短信验证码登录的时候:

  1. 先经过SmsAuthenticationFilter ,构造一个没有鉴权的 SmsAuthenticationToken,然后交给
    AuthenticationManager处理。
  2. AuthenticationManager通过 for-each 挑选出一个合适的 provider进行处理,当然我们希望这个
    provider要是 SmsAuthenticationProvider
  3. 验证通过后,重新构造一个有鉴权的 SmsAuthenticationToken,并返回给
    SmsAuthenticationFilter
    4.filter 根据上一步的验证结果,跳转到成功或者失败的处理逻辑。

二、代码实战

2.1 SmsAuthenticationToken

首先我们编写 SmsAuthenticationToken,这里直接参考 UsernamePasswordAuthenticationToken源码,直接粘过来,改一改

步骤:

  1. principal原本代表用户名,这里改成mobile,代表了手机号码。
  2. credentials 原本代码密码,短信登录用不到,直接删掉。
  3. SmsCodeAuthenticationToken() 两个构造方法一个是构造没有鉴权的,一个是构造有鉴权的。
  4. 剩下的几个方法去除无用属性即可。
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/**
 * 短信登录 AuthenticationToken,模仿 UsernamePasswordAuthenticationToken 实现
 */
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    /**
     * 在 UsernamePasswordAuthenticationToken 中该字段代表登录的用户名,
     * 在这里就代表登录的手机号码
     */
    private final Object principal; //存放认证信息。

    /**
     * 构建一个没有鉴权的 SmsCodeAuthenticationToken
     */
     //mobile:表示手机号。
    public SmsCodeAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        setAuthenticated(false);
    }

    /**
     * 构建拥有鉴权的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        // must use super, as we override
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    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");
        }

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

2.2 SmsAuthenticationFilter

然后编写 SmsAuthenticationFilter,参考 UsernamePasswordAuthenticationFilter的源码,直接粘过来,改一改。

步骤:

  1. 认证请求的方法必须为POST
  2. 从request中获取手机号
  3. 封装成自己的Authenticaiton的实现类SmsCodeAuthenticationToken(未认证)
  4. 调用 AuthenticationManagerauthenticate方法进行验证(即SmsCodeAuthenticationProvider
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	/**
     * form表单中手机号码的字段name
     */
	
	public static final String WU_FORM_MOBILE_KEY = "mobile";
	private String mobileParameter = "mobile";
	private boolean postOnly = true;  //只处理Post请求。

	//请求的匹配器。
	public SmsCodeAuthenticationFilter() {
		super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
	}

	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		//是否仅 POST 方式
		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();
			//这里封装未认证的Token
			SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
			//将请求信息也放入到Token中。
			this.setDetails(request, authRequest);
			//首先进入方法这里会找到我们自己写的SmsCodeAuthenticationProvider.
			//最后将结果放回到这里之后,经过AbstractAuthenticationProcessingFilter,这个抽象类的doFilter,然后调用处理器。成功调用成功处理器,失败调用失败处理器。
			return this.getAuthenticationManager().authenticate(authRequest);
		}
	}

	/**
	 * 获取手机号的方法
	 * @param request
	 * @return
	 */
	protected String obtainMobile(HttpServletRequest request) {
		return request.getParameter(this.mobileParameter);
	}
	//将请求信息也放入到Token中。
	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 mobileParameter() {
		return this.mobileParameter;
	}

}

2.3 SmsAuthenticationProvider

这个方法比较重要,这个方法首先能够在使用短信验证码登陆时候被 AuthenticationManager挑中,其次要在这个类中处理验证逻辑。
Spring Security实现短信登录_第3张图片

步骤:

  1. 实现 AuthenticationProvider接口,实现 authenticate() 和 supports() 方法。
  2. supports() 方法决定了这个 Provider 要怎么被 AuthenticationManager挑中,我这里通过
    return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication),处理所有
    SmsCodeAuthenticationToken及其子类或子接口。
  3. authenticate()方法处理验证逻辑。
    1. 首先将 authentication强转为 SmsCodeAuthenticationToken
    2. 从中取出登录的 principal,也就是手机号。
    3. 如果此时仍然没有异常,通过调用 loadUserByUsername(mobile) 读取出数据库中的用户信息。
    4. 如果仍然能够成功读取,没有异常,这里验证就完成了。
    5. 重新构造鉴权后的 SmsCodeAuthenticationToken,并返回给 SmsCodeAuthenticationFilter
  4. SmsCodeAuthenticationFilter的父类在 doFilter()方法中处理是否有异常,是否成功,根据处理结果跳转到登录成功/失败逻辑。
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
 */
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        String mobile = (String) authenticationToken.getPrincipal();

        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);

        // 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

hardcode的方式来模拟手机号查询用户信息

向手机13012345678发送短信验证码3237
2019-11-18 10:35:56.351  INFO 180980 --- [nio-8060-exec-6] c.i.s.c.v.code.ValidateCodeFilter        : 校验请求(/authentication/mobile)中的验证码,验证码类型SMS
2019-11-18 10:35:56.351  INFO 180980 --- [nio-8060-exec-6] c.i.s.c.v.code.ValidateCodeFilter        : 验证码校验通过
2019-11-18 10:36:11.425  INFO 180980 --- [nio-8060-exec-6] com.imooc.security.MyUserDetailsService  : 表单登录用户名:13012345678
2019-11-18 10:36:12.961  INFO 180980 --- [nio-8060-exec-6] com.imooc.security.MyUserDetailsService  : 登录用户名:13012345678
2019-11-18 10:36:25.432  INFO 180980 --- [nio-8060-exec-6] com.imooc.security.MyUserDetailsService  : 数据库密码是:$2a$10$hC7oYh4G4iUAXrLq3hFZtezWCL07UP9pPwp8zZYPZwPTraV8CCCta
Disconnected from the target VM, address: '127.0.0.1:57718', transport: 'socket'
2019-11-18 10:38:33.638  INFO 180980 --- [nio-8060-exec-6] .s.b.a.ImoocAuthenticationSuccessHandler : 登录成功

开头就说过了,为了方便介绍,写最少的代码,因此这是一个假的短信登录。如果你看这里的 UserDetailsService
的代码话,你会发现它是从数据库中根据 name 获取信息的,我其实就是把用户名来当手机号用。
这里只是一个demo,所以没有写查询数据库的逻辑,只是通过hardcode的方式来模拟查询数据库这个过程,用手机号作为用户名来验证用户信息
因此,如果你想根据数据库中其他字段,例如 phone 来得到用户信息,可以再写一个叫做
SmsUserDetailsService,在这里注入到 provider 中。

2.4 成功与失败处理逻辑

上面最后说到,在 SmsCodeAuthenticationFilter 的父类,会根据验证结果跳转到成功或失败处理逻辑,现在我们就编写下这个的处理。

验证成功处理:

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private ObjectMapper objectMapper;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

验证失败处理:

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Autowired
    private ObjectMapper objectMapper;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        logger.info("登陆失败");

        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
    }
}

2.5 SmsCodeAuthenticationSecurityConfig

下面我们需要把我们自己写的这么多类添加进 Spring Security 框架中,在以往,我们都是直接往 WebSecurityConfig 中加,但是这样会导致 WebSecurityConfig 内容太多,难以维护。

因此我们可以为每种登录方式都建议一个专属于它的配置文件,再把这个配置文件加入到 WebSecurityConfig 中,进行解耦。

因此建立短信验证码登录的配置文件SmsCodeAuthenticationSecurityConfig :

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

在这个配置文件中,首先给 SmsCodeAuthenticationFilter 指定了:

AuthenticationManager:不指定这个上面的流程图就断掉了。
指定登录成功/失败处理逻辑,方便其父类调用。
然后指定了 SmsCodeAuthenticationProvider,并指定了 UserDetailsService ,方便在验证处理时候通过 loadUserByUsername() 读取出数据库中的用户信息。

最后将 filter 和 provider 都加入 HttpSecurity 配置中。

将上述的逻辑加入到一起,放在BrowserSecurityConfig中去

下面我们就需要把自己写的 SmsCodeAuthenticationSecurityConfig加入到 SecurityConfig 中了。
首先将 SmsCodeAuthenticationSecurityConfig 注入进来,然后通过 http.apply(xxx) 添加进去。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.apply(smsCodeAuthenticationSecurityConfig).and().authorizeRequests()
                // 如果有允许匿名的url,填在下面
                .antMatchers("/sms/**").permitAll()
                .anyRequest().authenticated()
                .and()
                // 设置登陆页
                .formLogin().loginPage("/login")
                // 设置登陆成功页
                .defaultSuccessUrl("/").permitAll()
                .and()
                .logout().permitAll();

        // 关闭CSRF跨域
        http.csrf().disable();![在这里插入图片描述](https://img-blog.csdnimg.cn/20191118133448705.gif)
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 设置拦截忽略文件夹,可以对静态资源放行
        web.ignoring().antMatchers("/css/**", "/js/**");
    }
}

你可能感兴趣的:(security,Spring,Security实现短信登录)