与验证码登录逻辑是不一样的,所以不能使用Spring Security默认提供的那套逻辑;需要自个去写一个自定义身份认证逻辑
短信验证码生成
- 生成验证码
- 短信验证码类
ValidateCode是父类,ImageCode子类
public class ValidateCode { private String code; /** * 过期时间 */ private LocalDateTime expireTime; public ValidateCode(String code, int expireIn){ this.code=code; /** * 过期时间传递的参数应该是一个秒数:根据这个秒数去计算过期时间 */ this.expireTime = LocalDateTime.now().plusSeconds(expireIn); } public boolean isExpried() { return LocalDateTime.now().isAfter(expireTime); } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public LocalDateTime getExpireTime() { return expireTime; } public void setExpireTime(LocalDateTime expireTime) { this.expireTime = expireTime; } } public class ImageCode extends ValidateCode{ private BufferedImage image; public ImageCode(BufferedImage image,String code,int expireIn){ super(code,expireIn); this.image=image; } public BufferedImage getImage() { return image; } public void setImage(BufferedImage image) { this.image = image; } }
3. 短信验证码生成类 ,Bean配置类
实现ValidateCodeGenerator接口的generator方法。
public interface ValidateCodeGenerator {
/**
* 生成验证码
* @param request
* @return
*/
ValidateCode generate(ServletWebRequest request);
}
实际业务中,只需要生成随机的验证码,所以其它模块不需要重载自己的生成方法,SmsCodeGenerator直接@Component,不需要配置类
注意:imageCodeGenerator也是返回ValidateCodeGenerator,会造成@Autowired注入无法判断,需要声明生成的类名:
@Component("smsCodeGenerator"),注入时的变量,框架就会识别
@Autowired
private ValidateCodeGenerator imageCodeGenerator;
@Autowired
private ValidateCodeGenerator smsCodeGenerator;
如果是配置类,@Bean(name=”xxxx”)
4. 短信发送接口及默认实现类,Bean配置类
实际业务场景,其它模块使用的其它的短信运营商,通过实现接口方法来自定义选择短信运营商。
默认实现SendSmsCode接口send方法。
public interface SmsCodeSender {
/**
* 给某个手机发送短信验证码
* @param mobile
* @param code
*/
void send(String mobile,String code);
}
模拟定义默认接口发送实现类
public class DefaultSmsCodeSender implements SmsCodeSender {
@Override
public void send(String mobile, String code) {
System.out.println("向手机:"+mobile+" 发送短信验证码:"+code);
}
}
配置类:容器中有SendSmsCode,则不创建,否则创建。实现替换短信运营商发信短信
@Bean
@ConditionalOnMissingBean(SendSmsCode.class)
public SendSmsCode smsCodeSender(){
DefaultSmsCodeSender defaultSmsCodeSender = new DefaultSmsCodeSender(securityProperties);
return defaultSmsCodeSender;
}
5. 配置类
- 取短信验证码如下属性SmsCodeProperties
public class SmsCodeProperties {
private int length = 6;//长度
private int expireIn = 60;//过期时间
private String url;//要处理的url
//getter setter
}
- ImageCodeProperties继承SmsCodeProperties
public class ImageCodeProperties extends SmsCodeProperties{
private int width = 67;
private int height = 23;
public ImageCodeProperties(){
setLength(4);
}
}
- ValidateCodeProperties配置
public class ValidateCodeProperties {
private ImageCodeProperties image = new ImageCodeProperties();
private SmsCodeProperties sms = new SmsCodeProperties();
//getter setter
}
- 添加短信验证码生成器
短信验证码生成器,使用@Component("smsCodeGenerator")注解注入到Spring
图片验证码生成器, @Bean @ConditionalOnMissingBean(name="imageCodeGenerator")注解注入到Spring
@Component("smsCodeGenerator")
public class SmsCodeGenerator implements ValidateCodeGenerator {
private SecurityProperties securityProperties;
@Override
public ValidateCode generate(ServletWebRequest request) {
String code = RandomStringUtils.randomNumeric(securityProperties.getCode().getSms().getLength());
return new ValidateCode(code,securityProperties.getCode().getSms().getExpireIn());
}
public SecurityProperties getSecurityProperties() {
return securityProperties;
}
public void setSecurityProperties(SecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
}
- ValidateCodeController短信验证码生成
@RestController
public class ValidateCodeController {
public static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
@Autowired
private ValidateCodeGenerator imageCodeGenerator;
@Autowired
private ValidateCodeGenerator smsCodeGenerator;
@Autowired
private SmsCodeSender smsCodeSender;
@GetMapping("/code/image")
public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
/**
* 1.根据随机数生成图片
* 2.将随机数存到session中
* 3.将生成图片写到接口的响应中
*/
ImageCode imageCode = (ImageCode) imageCodeGenerator.generate(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,imageCode);
ImageIO.write(imageCode.getImage(),"JPEG",response.getOutputStream());
}
@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
/**
* 1.根据随机数生成图片
* 2.将随机数存到session中
* 3.调用短信服务:将短信发送到指定平台
*/
ValidateCode smsCode = smsCodeGenerator.generate(new ServletWebRequest(request));
sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,smsCode);
//3.调用短信服务:将短信发送到指定平台,我们封装成如下接口:
String mobile = ServletRequestUtils.getRequiredStringParameter(request,"mobile");
smsCodeSender.send(mobile,smsCode.getCode());
}
}
重构,图片生成和短信生成有一部分代码是相同的,可以使用模板方法模式抽象出来
整体的生成逻辑在ValidateCodeProcessor里面(包括创建,存储,发送),实现在AbstractValidateCodeProcessor里面,创建逻辑是在ValidateCodeGenerator里面,而ImageCodeGenerator和smsCodeGenerator都是继承于ValidateCodeGenerator来实现
- 创建ValidateCodeProcessor接口
创建验证码的不同处理逻辑接口方法
(2)AbstractValidateCodeProcessor抽象类
a. 实现了验证码公共逻辑,以及可重写的方法send
b. spring开发技巧:依赖查找
图片和短信验证码的生成都是实现ValidateCodeGenerator接口的generator方法,现在已知实现该接口的类有两个(业务需要增加emil验证码)
AbstractValidateCodeProcessor抽象类定义成员属性 :
@Autowired
private Map<String,ValidateCodeGenerator> validateCodeGenerators;
增加了注入注解后,spring在启动后,会查找spring容器里所有ValidateCodeGenerator接口的实现Bean,以Bean的名字为map的key,Bean为value
c. 区分生成验证码方法的Bean
请求的url: /code/image 、/code/sms
当前请求取得url的最后斜杠后面部,再加上CodeGenerator,就是创建是Bean的名字,从map中取得对应的Bean调用生成验证码方法。
(3)ImageCodeProcessor继承AbstractValidateCodeProcessor并重写send方法,验证码图片发送到客户端
(4)SmsCodeProcessor继承AbstractValidateCodeProcessor并重写send方法,验证码通过短信发送到客户手机
(5)修改ValidateCodeController的api方法
两个接口改为一个接口
(6)修改接口访问权限 /code/*
短信验证码认证
回顾密码登陆流程
- 首先密码登录请求:/authentication/form会给到UsernamePasswordAuthenticationFilter(此过滤器会拿出用户名/密码,组装成未认证的UsernamePasswordAuthenticationToken;传给AuthenticationManager)
- AuthenticationManager会从一堆AuthenticationProvider里面去挑选一个provider来处理认证请求(挑选的依据是:AuthenticationProvider有一个support方法,此support判断当前的provider是否支持你传递过来的token,如果支持的话就用对应的provider处理:成未认证的UsernamePasswordAuthenticationToken;这里用到DaoAuthenticationProvider)
- DaoAuthenticationProviderren认证过程中会调用UserDetailsService去获取用户信息UserDetails;跟请求传过来的登录信息做一些比较;判断是否可以认证通过。
- 认证通过的话最终会把:未认证的UsernamePasswordAuthenticationToken作为一个标记:Authentication(已认证)
短信登录详细流程
短信验证码登陆,不需要用户帐号和密码,只要用户手机(用户名)和短信验证码,所以表单登陆(UsernamePasswordAutheticationFilter)过滤器是无效的,也不能修改源码。
由于短信登录方式只需要使用随机验证码进行校验而不需要密码登录功能,当校验成功之后就认为用户认证成功了,因此开发自定义的短信登录认证 token,这个 token 只需要存放手机号即可,在token 校验的过程中,不能使用默认的校验器了,需要自己开发校验当前自定义 token 的校验器(Provider),最后将自定义的过滤器和校验器配置到 spring security 框架中即可。
实现短信验证码登录逻辑验证,需要自己实现的有SmsAuthenticationFilter和SmsAuthenticationToken以及SmsAuthenticationProvider
- 短信登录请求(/authentication/mobile)给SmsAuthenticationFilter(此过滤器会拿出mobile,组装成未认证的SmsAuthenticationToken;传给AuthenticationManager:因为AuthenticationManager整个系统就只有一个)。
- AuthenticationManager会从一堆AuthenticationProvider里面去挑选一个provider来处理认证请求(挑选的依据是:AuthenticationProvider有一个support方法,此support判断当前的provider是否支持你传递过来的token,如果支持的话就用对应的provider处理:成未认证的SmsAuthenticationFilter;这里用到SmsAuthenticationProvider:需要我们自己实现)
- SmsAuthenticationProvider认证过程中会调用UserDetailsService去获取用户信息UserDetails;跟请求传过来的登录信息做一些比较;判断是否可以认证通过。
注意:
- 此过程中说了3个东西:Filter、Token、Provider;这3个类作用的过程中,我们不会去校验短信验证码这里只是根据手机号去做用户信息认证。短信验证码的校验类似于图形验证码的校验,他是在Filter请求之前进行校验。 加了一个过滤器来在SmsAuthenticationFilter之前校验密码。
- 我们为什么不把短信验证码功能卸载SmsAuthentiationProvider里面,这是因为:验短信验证码的功能我们还希望给其他请求去使用;假如我们需要开发一个支付功能,每次支付完成前都需要做短信验证码校验,如果我们写在SmsAuthentiationProvider里面,那么短信验证码的功能是不能重复利用的:因为其包含额外短信验证码不需要的信息。
1. 短信登录认证Token,创建token
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 420L;
private final Object principal;
/**
* 没登录之前,principal我们使用手机号
* @param mobile
*/
public SmsCodeAuthenticationToken(String mobile) {
super((Collection)null);
this.principal = mobile;
this.setAuthenticated(false);
}
/**
* 登录认证之后,principal我们使用用户信息
* @param principal
* @param authorities
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}
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();
}
}
为什么要自定义一个短信验证的 token,spring security 框架不只提供了用户名+密码的验证方式,用户认证是否成功,最终看的就是SecurityContextHolder对象中是否有对应的AuthenticationToken,因此要设计一个认证对象,当认证成功之后,将其设置到SecurityContextHolder即可
在认证之前放的是用户手机号,认证之后放的是用户信息.
已加入到框架中
- 创建filter,生成 Token信息
SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter
仿UsernamePasswordAuthenticationFilter
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
/**
* 在我们认证过程中是不需要密码,认证信息是手机号
*/
public static final String YXM_FORM_MOBILE_KEY = "mobile";
/**
* 请求中携带参数的名字是什么?
*/
private String mobileParameter = "mobile";
/**
* 此过滤器只处理post请求
*/
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {
//指明当前过滤器处理的请求是什么?/authentication/mobile --这个地址不能写错,不然不会走此认证过滤器方法
super(new AntPathRequestMatcher("/authentication/mobile", "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();
//实例化未认证的token
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
this.setDetails(request, authRequest);
/**
* AuthenticationManager进行调用
*/
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, "Username 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;
}
}
- 创建Provider, 根据token类型AuthenticationManager 会调用相应的Provider,获取UserDetailsService user信息等
SmsCodeAuthenticationProvider implements AuthenticationProvider
AuthenticationManager挑选Provider处理token时,会调用所有Provider.supports方法来判断传入此方法的参数是否为相对应的token类,如果是就挑选当前的Provider来处理token
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
/**
* 校验逻辑很简单:用SmsCodeAuthenticationToken信息调用UserDetailService去数据库校验
* 将此对象变成一个已经认证的认证数据
*/
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if(user == null){
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
//.没有错误说明认证成功
final SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
/**
* 此方法就是检验AuthenticationManager会使用哪个provider
* @param authentication
* @return
*/
@Override
public boolean supports(Class<?> authentication) {
//此方法就是检验AuthenticationManager会使用哪个provider
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
}
- 创建SmsCodeFilter,与ValidateCodeFilter逻辑一样
校验短信验证码
public class SmsCodeFilter extends OncePerRequestFilter implements InitializingBean {
private AuthenticationFailureHandler authenticationFailureHandler;
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
private Logger logger = LoggerFactory.getLogger(getClass());
//需要校验的url都在这里面添加
private Set<String> urls = new HashSet<>();
private AntPathMatcher antPathMatcher = new AntPathMatcher();
//此处不用注解@Autowire 而是使用setter方法将在WebSecurityConfig设置
private SecurityProperties securityProperties;
@Override
public void afterPropertiesSet() throws ServletException {
super.afterPropertiesSet();
/**
* 拦截短信验证码需要拦截的地址url(包括自定义url和登录时候url):这些url都需要经过这个过滤器
*/
String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getSms().getUrl(), ",");
if(configUrls!=null && configUrls.length>0){
for (String configUrl:configUrls) {
urls.add(configUrl);
}
}
//"/authentication/moble 一定会校验验证码的
urls.add("/authentication/mobile");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
logger.info("验证码过滤器:doFilterInternal: requestURI:[{}] requestMethod:[{}]",request.getRequestURI(),request.getMethod());
/**
* 如果是需要认证请求,我们进行家宴
* 如果校验失败,使用我们自定义的校验失败处理类处理
* 如果不需要认证,我们放行进入下一个Filter
*/
//在afterPropertiesSet执行之后,url初始化完毕之后,但是此时我们判断不能用StringUtils.equals,我们我们urls里面有 url: /user,/user/* 带星号的配置
// 用户请求有可能是/user/1、/user/2 我们需要使用Spring的 AntPathMatcher
boolean action = false;
for (String url:urls) {
//如果配置的url和请求的url相同时候,需要校验
if(antPathMatcher.match(url,request.getRequestURI())){
action = true;
}
}
if(action){
try{
validate(new ServletWebRequest(request));
}catch (ValidateCodeException e){
authenticationFailureHandler.onAuthenticationFailure(request,response,e);
//抛出异常校验失败,不再走小面过滤器执行链
return;
}
}
filterChain.doFilter(request,response);
}
private void validate(ServletWebRequest servletWebRequest) throws ServletRequestBindingException {
//1.获取存放到session中的验证码
ValidateCode codeInSession = (ValidateCode)sessionStrategy.getAttribute(servletWebRequest, ValidateCodeProcessor.SESSION_KEY_PREFIX+"SMS");
//2.获取请求中的验证码
String codeInRequest = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(), "smsCode");
if(StringUtils.isBlank(codeInRequest)){
throw new ValidateCodeException("验证码的值不能为空");
}
if(codeInSession == null){
throw new ValidateCodeException("验证码不存在");
}
if(codeInSession.isExpried()){
sessionStrategy.removeAttribute(servletWebRequest,ValidateCodeProcessor.SESSION_KEY_PREFIX+"SMS");
throw new ValidateCodeException("验证码已过期");
}
if(!StringUtils.equals(codeInSession.getCode(),codeInRequest)){
throw new ValidateCodeException("验证码不匹配");
}
sessionStrategy.removeAttribute(servletWebRequest,ValidateCodeProcessor.SESSION_KEY_PREFIX+"SMS");
}
public AuthenticationFailureHandler getAuthenticationFailureHandler() {
return authenticationFailureHandler;
}
public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
this.authenticationFailureHandler = authenticationFailureHandler;
}
public SecurityProperties getSecurityProperties() {
return securityProperties;
}
public void setSecurityProperties(SecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
}
5. 创建配置类
SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>
实现configure 方法 添加自定义的SmsCodeAuthenticationProvider, SmsCodeAuthenticationFilter
SecurityConfigurer用于配置Security过滤链上相关的类
SmsCodeFilter也是过滤器,区别在于它不需要Manager挑选Provider处理token,只是简单的拦截校验验证码
BrowserSecurityConfig配置SmsCodeFilter加入过滤器链
@Configuration
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private UserDetailsService myUserDetailsService;
@Override
public void configure(HttpSecurity http) throws Exception {
/* super.configure(builder);*/
//1.按照过滤器链路:配置SmsCodeAutnenticationFilter-->配置让其加入到AuthenticationManager中 配置其成功处理 失败处理的Handler
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
//2.配置SmsCodeAuthenticationProvider-->添加UserDetailService
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(myUserDetailsService);
//3.http配置:配置SmsCodeAutnenticationFilter执行位置--->在UsernamePasswordAuthenticationFilter之后
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
- 验证码过滤器配置
应用层配置:SmsCodeFilter在UsernamePasswordFilter前面
- 在BrowserSecurityConfig引用:
.apply(smsCodeAuthenticationSecurityConfig);
回头重构
重构一些重复的代码
校验验证码的filter配置有重复,抽象出来封装到一个类中。
表单登陆也抽出来封装到类中
重构系统配置相关代码结构
- ValidateCodeFilter
将两个验证码过滤器合二为一。
- BrowserSecurityConfig的表单登陆相关配置抽离出来并继承抽离出来的类
- BrowserSecurityConfig的图片和短信验证码合二为一filter加入过滤器链的配置抽离出来