Spring Security 入门(二):图形验证码和手机短信验证码


本文在前文 的基础上介绍图形验证码和手机短信验证码登录的实现。

图形验证码

在用户登录时,一般通过表单的方式进行登录都会要求用户输入验证码,Spring Security默认没有实现图形验证码的功能,所以需要我们自己实现。

实现流程分析

前文中实现的用户名、密码登录是在UsernamePasswordAuthenticationFilter过滤器进行认证的,而图形验证码一般是在用户名、密码认证之前进行验证的,所以需要在UsernamePasswordAuthenticationFilter过滤器之前添加一个自定义过滤器 ImageCodeValidateFilter,用来校验用户输入的图形验证码是否正确。自定义过滤器继承 OncePerRequestFilter 类,该类是 Spring 提供的在一次请求中只会调用一次的 filter。

自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求,如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException,该异常类需要继承 AuthenticationException 类。在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理。


kpatcha 使用

Kaptcha 是谷歌提供的生成图形验证码的工具,参考地址为:https://github.com/penggle/kaptcha,依赖如下:



    com.github.penggle
    kaptcha
    2.3.2

? 创建 KaptchaConfig 配置类

package com.example.config;

import com.google.code.kaptcha.Constants;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;

/**
 * 图形验证码的配置类
 */
@Configuration
public class KaptchaConfig {

    @Bean
    public DefaultKaptcha captchaProducer() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        // 是否有边框
        properties.setProperty(Constants.KAPTCHA_BORDER, "yes");
        // 边框颜色
        properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "192,192,192");
        // 验证码图片的宽和高
        properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "110");
        properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "40");
        // 验证码颜色
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "0,0,0");
        // 验证码字体大小
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "32");
        // 验证码生成几个字符
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
        // 验证码随机字符库
        properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
        // 验证码图片默认是有线条干扰的,我们设置成没有干扰
        properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

kaptcha 配置的参数说明(定义在 Constants 常量类中):

  • kaptcha.border:是否有图片边框,合法值:yes,no;默认值为 yes。
  • kaptcha.border.color:边框颜色,合法值:rgb 或者 white,black,blue;默认值为 black。
  • kaptcha.border.thickness 边框厚度,合法值:>0;默认为 1。
  • kaptcha.image.width:图片宽,默认值为 200px。
  • kaptcha.image.height:图片高,默认值为 50px。
  • kaptcha.producer.impl:图片实现类,默认值为com.google.code.kaptcha.impl.DefaultKaptcha
  • kaptcha.textproducer.impl:文本实现类,默认值为com.google.code.kaptcha.text.impl.DefaultTextCreator
  • kaptcha.textproducer.char.string:文本集合,验证码值从此集合中获取,默认值为 abcde2345678gfynmnpwx
  • kaptcha.textproducer.char.length:验证码长度,默认值为 5。
  • kaptcha.textproducer.font.names:字体,默认值为 Arial, Courier。
  • kaptcha.textproducer.font.size:字体大小,默认值为 40px。
  • kaptcha.textproducer.font.color:字体颜色,合法值:rgb 或者 white,black,blue;默认值为black。
  • kaptcha.textproducer.char.space:文字间隔,默认值为 2px。
  • kaptcha.noise.impl:干扰实现类,com.google.code.kaptcha.impl.NoNoise为没有干扰。默认值为 com.google.code.kaptcha.impl.DefaultNoise
  • kaptcha.noise.color:干扰线颜色,合法值:rgb 或者 white,black,blue;默认值为 black。
  • kaptcha.obscurificator.impl:图片样式,合法值:水纹 com.google.code.kaptcha.impl.WaterRipple,鱼眼 com.google.code.kaptcha.impl.FishEyeGimpy, 阴影 com.google.code.kaptcha.impl.ShadowGimpy;默认值为 com.google.code.kaptcha.impl.WaterRipple
  • kaptcha.background.impl:背景实现类,默认值为com.google.code.kaptcha.impl.DefaultBackground
  • kaptcha.background.clear.from:背景颜色渐变,开始颜色,默认值为light grey
  • kaptcha.background.clear.to:背景颜色渐变, 结束颜色,默认值为 white。
  • kaptcha.word.impl:文字渲染器 实现类,默认值为 com.google.code.kaptcha.text.impl.DefaultWordRenderer
  • kaptcha.session.key:session key,默认值为KAPTCHA_SESSION_KEY
  • kaptcha.session.date:session date,默认值为KAPTCHA_SESSION_DATE

? 创建验证码的实体类 CheckCode

package com.example.entity;

import java.io.Serializable;
import java.time.LocalDateTime;

public class CheckCode implements Serializable {
    private String code;           // 验证码字符
    private LocalDateTime expireTime;  // 过期时间

    /**
     * @param code 验证码字符
     * @param expireTime 过期时间,单位秒
     */
    public CheckCode(String code, int expireTime) {
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
    }

    public CheckCode(String code) {
        // 默认验证码 60 秒后过期
        this(code, 60);
    }

    // 是否过期
    public boolean isExpried() {
        return this.expireTime.isBefore(LocalDateTime.now());
    }

    public String getCode() {
        return this.code;
    }
}

? 在 LoginController 中添加获取图形验证码的 Controller 方法

package com.example.constans;

public class Constants {
    // Session 中存储图形验证码的属性名
    public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";
}

@Controller
public class LoginController {

    @Autowired
    private DefaultKaptcha defaultKaptcha;
    //...
    
    @GetMapping("/code/image")
    public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 创建验证码文本
        String capText = defaultKaptcha.createText();
        // 创建验证码图片
        BufferedImage image = defaultKaptcha.createImage(capText);

        // 将验证码文本放进 Session 中
        CheckCode code = new CheckCode(capText);
        request.getSession().setAttribute(Constants.KAPTCHA_SESSION_KEY, code);

        // 将验证码图片返回,禁止验证码图片缓存
        response.setHeader("Cache-Control", "no-store");
        response.setHeader("Pragma", "no-cache");
        response.setDateHeader("Expires", 0);
        response.setContentType("image/jpeg");
        ImageIO.write(image, "jpg", response.getOutputStream());
    }    
}

? 在 login.html 中添加验证码功能




    
    登录


    

表单登录




验证码
用户名或密码错误

? 更改安全配置类 SpringSecurityConfig,设置访问/code/image不需要任何权限

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page", "/code/image").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();
		//...            
    }
    //...
}

? 测试

访问localhost:8080/login/page,出现图形验证的信息


自定义验证码过滤器

?? 创建自定义异常类 ValidateCodeException

package com.example.exception;

import org.springframework.security.core.AuthenticationException;

/**
 * 自定义验证码校验错误的异常类,继承 AuthenticationException
 */
public class ValidateCodeException extends AuthenticationException {
    public ValidateCodeException(String msg, Throwable t) {
        super(msg, t);
    }

    public ValidateCodeException(String msg) {
        super(msg);
    }
}

? 自定义图形验证码校验过滤器 ImageCodeValidateFilter

package com.example.config.security;

import com.example.constans.Constants;
import com.example.entity.CheckCode;
import com.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@Component
public class ImageCodeValidateFilter extends OncePerRequestFilter {

    private String codeParamter = "imageCode";  // 前端输入的图形验证码参数名

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler;  // 自定义认证失败处理器

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 非 POST 方式的表单提交请求不校验图形验证码
        if ("/login/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
            try {
                // 校验图形验证码合法性
                validate(request);
            } catch (ValidateCodeException e) {
                // 手动捕获图形验证码校验过程抛出的异常,将其传给失败处理器进行处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }

        // 放行请求,进入下一个过滤器
        filterChain.doFilter(request, response);
    }

    // 判断验证码的合法性
    private void validate(HttpServletRequest request) {
        // 获取用户传入的图形验证码值
        String requestCode = request.getParameter(this.codeParamter);
        if(requestCode == null) {
            requestCode = "";
        }
        requestCode = requestCode.trim();

        // 获取 Session
        HttpSession session = request.getSession();
        // 获取存储在 Session 里的验证码值
        CheckCode savedCode = (CheckCode) session.getAttribute(Constants.KAPTCHA_SESSION_KEY);
        if (savedCode != null) {
            // 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码
            session.removeAttribute(Constants.KAPTCHA_SESSION_KEY);
        }

        // 校验出错,抛出异常
        if (StringUtils.isBlank(requestCode)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }

        if (savedCode == null) {
            throw new ValidateCodeException("验证码不存在");
        }

        if (savedCode.isExpried()) {
            throw new ValidateCodeException("验证码过期");
        }

        if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
            throw new ValidateCodeException("验证码输入错误");
        }
    }
}

?? 更改安全配置类 SpringSecurityConfig,将自定义过滤器添加过滤器链中

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
    //...
    
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...        
        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
    }
    //...
}

完整的安全配置类 SpringSecurityConfig 如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器

    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)

    /**
     * 密码编码器,密码不能明文存储
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用户认证管理器来实现用户认证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用内存存储方式,用户认证信息存储在内存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
                // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
                .loginPage("/login/page")
                // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
                .loginProcessingUrl("/login/form")
                // 设置登录表单中的用户名参数,默认为 username
                .usernameParameter("name")
                // 设置登录表单中的密码参数,默认为 password
                .passwordParameter("pwd")
                // 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
                //.defaultSuccessUrl("/index")
                // 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
                // 使用自定义的认证成功和失败处理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page", "/code/image").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();

        // 关闭 csrf 防护
        http.csrf().disable();
        
        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);        
    }

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

? 测试

访问localhost:8080/login/page,等待 60 秒后,输入正确的用户名、密码和验证码:

验证码过期,重定向到localhost:8080/login/page?error,显示错误信息:


手机短信验证码

一般登录除了用户名、密码登录,还可以使用手机短信验证码登录,Spring Security默认没有实现手机短信验证码的功能,所以需要我们自己实现。

实现流程分析

手机短信验证码登录和前面的带有图形验证码的用户名、密码登录流程类似,红色标记的部分是需要我们自定义实现的类。

我们首先分析下带有图形验证码的用户名、密码登录流程:

  1. 在 ImageCodeValidateFilter 过滤器中校验用户输入的图形验证码是否正确。

  2. 在 UsernamePasswordAuthenticationFilter 过滤器中将 username 和 password 生成一个用于认证的 Token(UsernamePasswordAuthenticationToken),并将其传递给 ProviderManager 接口的实现类 AuthenticationManager。

  3. AuthenticationManager 管理器寻找到一个合适的处理器 DaoAuthenticationProvider 来处理 UsernamePasswordAuthenticationToken。

  4. DaoAuthenticationProvider 通过 UserDetailsService 接口的实现类 CustomUserDetailsService 从数据库中获取指定 username 的相关信息,并校验用户输入的 password。如果校验成功,那就认证通过,用户信息类对象 Authentication 标记为已认证。

  5. 认证通过后,将已认证的用户信息对象 Authentication 存储到 SecurityContextHolder 中,最终存储到 Session 中。

仿照上述流程,我们分析手机短信验证码登录流程:

  1. 仿照 ImageCodeValidateFilter 过滤器设计 MobileVablidateFilter 过滤器,该过滤器用来校验用户输入手机短信验证码。
  2. 仿照 UsernamePasswordAuthenticationFilter 过滤器设计 MobileAuthenticationFilter 过滤器,该过滤器将用户输入的手机号生成一个 Token(MobileAuthenticationToken),并将其传递给 ProviderManager 接口的实现类 AuthenticationManager。
  3. AuthenticationManager 管理器寻找到一个合适的处理器 MobileAuthenticationProvider 来处理 MobileAuthenticationToken,该处理器是仿照 DaoAuthenticationProvider 进行设计的。
  4. MobileAuthenticationProvider 通过 UserDetailsService 接口的实现类 MobileUserDetailsService 从数据库中获取指定手机号对应的用户信息,此处不需要进行任何校验,直接将用户信息类对象 Authentication 标记为已认证。
  5. 认证通过后,将已认证的用户信息对象 Authentication 存储到 SecurityContextHolder 中,最终存储到 Session 中,此处的操作不需要我们编写。

最后通过自定义配置类 MobileAuthenticationConfig 组合上述组件,并添加到安全配置类 SpringSecurityConfig 中。


模拟发送短信验证码

?? 在 UserMapper 接口中添加根据 mobile 查询用户的方法

public interface UserMapper {
    //...
    @Select("select * from user where mobile = #{mobile}")
    User selectByMobile(String mobile);    
}

?? 创建 UserService 类,编写判断指定 mobile 是否存在的方法

package com.example.service;

import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    /**
     * 判断指定 mobile 是否存在
     */
    public boolean isExistByMobile(String mobile) {
        return userMapper.selectByMobile(mobile) != null;
    }
}

?? 创建 MobileCodeSendService 类,模拟手机短信验证码发送服务

package com.example.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class MobileCodeSendService {

    /**
     * 模拟发送手机短信验证码
     */
    public void send(String mobile, String code) {
        String sendContent = String.format("验证码为 %s,请勿泄露!", code);
        log.info("向手机号 " + mobile + " 发送短信:" + sendContent);
    }
}

?? 在 LoginController 中添加手机短信验证码相关的 Controller 方法

public class Constants {
    //...
    // Session 中存储手机短信验证码的属性名
    public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY";
}
@Controller
public class LoginController {
    //...
    @Autowired 
    private MobileCodeSendService mobileCodeSendService;  // 模拟手机短信验证码发送服务

    @Autowired
    private UserService userService;   
    //...
    
    @GetMapping("/mobile/page")
    public String mobileLoginPage() {  // 跳转到手机短信验证码登录页面
        return "login-mobile";
    }

    @GetMapping("/code/mobile")
    @ResponseBody
    public Object sendMoblieCode(String mobile, HttpServletRequest request) { 
        // 随机生成一个 4 位的验证码
        String code = RandomStringUtils.randomNumeric(4);

        // 将手机验证码文本存储在 Session 中,设置过期时间为 10 * 60s
        CheckCode mobileCode = new CheckCode(code, 10 * 60);
        request.getSession().setAttribute(Constants.MOBILE_SESSION_KEY, mobileCode);

        // 判断该手机号是否注册
        if(!userService.isExistByMobile(mobile)) {
            return new ResultData<>(1, "该手机号不存在!");
        }

        // 模拟发送手机短信验证码到指定用户手机
        mobileCodeSendService.send(mobile, code);
        return new ResultData<>(0, "发送成功!");
    }    
}

?? 编写手机短信验证码登录页面 login-mobile.html




    
    登录页面
    


    

用户名或密码错误

?? 更改安全配置类 SpringSecurityConfig,设置访问/mobile/page/code/mobile不需要任何权限

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();
		//...            
    }
    //...
}

?? 测试

访问localhost:8080/mobile/page,页面显示:

手机号码输入11111111111,控制台输出:

向手机号 11111111111 发送短信:验证码为 2561,请勿泄露!

浏览器弹出窗口显示”发送成功“。


自定义认证流程配置

?? 自定义短信验证码校验过滤器 MobileValidateFilter

package com.example.config.security.mobile;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.constans.Constants;
import com.example.entity.CheckCode;
import com.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

/**
 * 手机短信验证码校验
 */
@Component
public class MobileCodeValidateFilter extends OncePerRequestFilter {

    private String codeParamter = "mobileCode";  // 前端输入的手机短信验证码参数名

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 非 POST 方式的手机短信验证码提交请求不进行校验
        if("/mobile/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
            try {
                // 检验手机验证码的合法性
                validate(request);
            } catch (ValidateCodeException e) {
                // 将异常交给自定义失败处理器进行处理
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }

        // 放行,进入下一个过滤器
        filterChain.doFilter(request, response);
    }

    /**
     * 检验用户输入的手机验证码的合法性
     */
    private void validate(HttpServletRequest request) {
        // 获取用户传入的手机验证码值
        String requestCode = request.getParameter(this.codeParamter);
        if(requestCode == null) {
            requestCode = "";
        }
        requestCode = requestCode.trim();


        // 获取 Session
        HttpSession session = request.getSession();
        // 获取 Session 中存储的手机短信验证码
        CheckCode savedCode = (CheckCode) session.getAttribute(Constants.MOBILE_SESSION_KEY);

        if (savedCode != null) {
            // 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码
            session.removeAttribute(Constants.MOBILE_SESSION_KEY);
        }

        // 校验出错,抛出异常
        if (StringUtils.isBlank(requestCode)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }

        if (savedCode == null) {
            throw new ValidateCodeException("验证码不存在");
        }

        if (savedCode.isExpried()) {
            throw new ValidateCodeException("验证码过期");
        }

        if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
            throw new ValidateCodeException("验证码输入错误");
        }
    }
}

?? 更改自定义失败处理器 CustomAuthenticationFailureHandler,原先的处理器在认证失败时,会直接重定向到/login/page?error显示认证异常信息。现在我们有两种登录方式,应该进行以下处理:

  • 带图形验证码的用户名、密码方式登录方式出现认证异常,重定向到/login/page?error
  • 手机短信验证码方式登录出现认证异常,重定向到/mobile/page?error
package com.example.config.security;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* 继承 SimpleUrlAuthenticationFailureHandler 处理器,该类是 failureUrl() 方法使用的认证失败处理器
*/
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

   @Autowired
   private ObjectMapper objectMapper;

   @Override
   public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
       String xRequestedWith = request.getHeader("x-requested-with");
       // 判断前端的请求是否为 ajax 请求
       if ("XMLHttpRequest".equals(xRequestedWith)) {
           // 认证成功,响应 JSON 数据
           response.setContentType("application/json;charset=utf-8");
           response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1, "认证失败!")));
       }else {
           // 用户名、密码方式登录出现认证异常,需要重定向到 /login/page?error
           // 手机短信验证码方式登录出现认证异常,需要重定向到 /mobile/page?error
           // 使用 Referer 获取当前登录表单提交请求是从哪个登录页面(/login/page 或 /mobile/page)链接过来的
           String refer = request.getHeader("Referer");
           String lastUrl = StringUtils.substringBefore(refer, "?");
           // 设置默认的重定向路径
           super.setDefaultFailureUrl(lastUrl + "?error");
           // 调用父类的 onAuthenticationFailure() 方法
           super.onAuthenticationFailure(request, response, e);
       }
   }
}

?? 自定义短信验证码认证过滤器 MobileAuthenticationFilter,仿照 UsernamePasswordAuthenticationFilter 过滤器进行编写

package com.example.config.security.mobile;

import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 手机短信验证码认证过滤器,仿照 UsernamePasswordAuthenticationFilter 过滤器编写
 */
public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private String mobileParamter = "mobile";  // 默认手机号参数名为 mobile
    private boolean postOnly = true;    // 默认请求方式只能为 POST

    protected MobileAuthenticationFilter() {
        // 默认登录表单提交路径为 /mobile/form,POST 方式请求
        super(new AntPathRequestMatcher("/mobile/form", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //(1) 默认情况下,如果请求方式不是 POST,会抛出异常
        if(postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }else {
            //(2) 获取请求携带的 mobile
            String mobile = request.getParameter(mobileParamter);
            if(mobile == null) {
                mobile = "";
            }
            mobile = mobile.trim();

            //(3) 使用前端传入的 mobile 构造 Authentication 对象,标记该对象未认证
            // MobileAuthenticationToken 是我们自定义的 Authentication 类,后续介绍
            MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);
            //(4) 将请求中的一些属性信息设置到 Authentication 对象中,如:remoteAddress,sessionId
            this.setDetails(request, authRequest);
            //(5) 调用 ProviderManager 类的 authenticate() 方法进行身份认证
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

    @Nullable
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(this.mobileParamter);
    }

    protected void setDetails(HttpServletRequest request, MobileAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    public void setMobileParameter(String mobileParamter) {
        Assert.hasText(mobileParamter, "Mobile par ameter must not be empty or null");
        this.mobileParamter = mobileParamter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public String getMobileParameter() {
        return mobileParamter;
    }
}

?? 自定义用户信息封装类 MobileAuthenticationToken,仿照 UsernamePasswordAuthenticationToken 类进行编写

package com.example.config.security.mobile;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;

public class MobileAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 520L;
    private final Object principal;

    /**
     * 认证前,使用该构造器进行封装信息
     */
    public MobileAuthenticationToken(Object principal) {
        super((Collection) null);     // 用户权限为 null
        this.principal = principal;   // 前端传入的手机号
        this.setAuthenticated(false); // 标记为未认证
    }

    /**
     * 认证成功后,使用该构造器封装用户信息
     */
    public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);          // 用户权限集合
        this.principal = principal;  // 封装认证用户信息的 UserDetails 对象,不再是手机号
        super.setAuthenticated(true); // 标记认证成功
    }

    @Override
    public Object getCredentials() {
        // 由于使用手机短信验证码登录不需要密码,所以直接返回 null
        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");
        } else {
            super.setAuthenticated(false);
        }
    }

    @Override
    public void eraseCredentials() {
        // 手机短信验证码认证方式不必去除额外的敏感信息,所以直接调用父类方法
        super.eraseCredentials();
    }
}

?? 自定义短信验证码认证的处理器 MobileAuthenticationProvider,仿照 DaoAuthenticationProvider 处理器进行编写

package com.example.config.security.mobile;

import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;

public class MobileAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    private UserDetailsChecker authenticationChecks = new MobileAuthenticationProvider.DefaultAuthenticationChecks();

    /**
     * 处理认证
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //(1) 如果入参的 Authentication 类型不是 MobileAuthenticationToken,抛出异常
        Assert.isInstanceOf(MobileAuthenticationToken.class, authentication, () -> {
            return this.messages.getMessage("MobileAuthenticationProvider.onlySupports", "Only MobileAuthenticationToken is supported");
        });

        // 获取手机号
        String mobile = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
        //(2) 根据手机号从数据库中查询用户信息
        UserDetails user = this.userDetailsService.loadUserByUsername(mobile);
        if (user == null) {
            //(3) 未查询到用户信息,抛出异常
            throw new AuthenticationServiceException("该手机号未注册");
        }

        //(4) 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期
        this.authenticationChecks.check(user);

        //(5) 查询到了用户信息,则认证通过,构建标记认证成功用户信息类对象 AuthenticationToken
        MobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities());
        // 需要把认证前 Authentication 对象中的 details 信息加入认证后的 Authentication
        result.setDetails(authentication.getDetails());
        return result;
    }

    /**
     * ProviderManager 管理器通过此方法来判断是否采用此 AuthenticationProvider 类
     * 来处理由 AuthenticationFilter 过滤器传入的 Authentication 对象
     */
    @Override
    public boolean supports(Class<?> authentication) {
        // isAssignableFrom 返回 true 当且仅当调用者为父类.class,参数为本身或者其子类.class
        // ProviderManager 会获取 MobileAuthenticationFilter 过滤器传入的 Authentication 类型
        // 所以当且仅当 authentication 的类型为 MobileAuthenticationToken 才返回 true
        return MobileAuthenticationToken.class.isAssignableFrom(authentication);
    }

    /**
     * 此处传入自定义的 MobileUserDetailsSevice 对象
     */
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    /**
     * 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期
     */
    private class DefaultAuthenticationChecks implements UserDetailsChecker {
        private DefaultAuthenticationChecks() {
        }

        @Override
        public void check(UserDetails user) {
            if (!user.isAccountNonLocked()) {
                throw new LockedException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
            } else if (!user.isEnabled()) {
                throw new DisabledException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
            } else if (!user.isAccountNonExpired()) {
                throw new AccountExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
            } else if (!user.isCredentialsNonExpired()) {
                throw new CredentialsExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired", "User credentials have expired"));
            }
        }
    }
}

?? 自定义 MobileUserDetailsService 类,MobileAuthenticationProvider 处理器传入的 UserDetailsService 对象的类型需要我们自定义

package com.example.service;

import com.example.entity.User;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class MobileUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
        //(1) 从数据库尝试读取该用户
        User user = userMapper.selectByMobile(mobile);
        // 用户不存在,抛出异常
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        //(2) 将数据库形式的 roles 解析为 UserDetails 的权限集合
        // AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用于将逗号隔开的权限集字符串切割为可用权限对象列表
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));

        //(3) 返回 UserDetails 对象
        return user;
    }
}

?? 自定义短信验证码认证方式配置类 MobileAuthenticationConfig,将上述组件进行管理

package com.example.config.security.mobile;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.service.MobileUserDetailsService;
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.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.stereotype.Component;

@Component
public class MobileAuthenticationConfig extends SecurityConfigurerAdapter {

    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;  // 自定义认证成功处理器

    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;  // 自定义认证失败处理器

    @Autowired
    private MobileCodeValidateFilter mobileCodeValidaterFilter;  // 手机短信验证码校验过滤器

    @Autowired
    private MobileUserDetailsService userDetailsService;  // 手机短信验证方式的 UserDetail

    @Override
    public void configure(HttpSecurity http) throws Exception {
        //(1) 将短信验证码认证的自定义过滤器绑定到 HttpSecurity 中
        //(1.1) 创建手机短信验证码认证过滤器的实例 filer
        MobileAuthenticationFilter filter = new MobileAuthenticationFilter();

        //(1.2) 设置 filter 使用 AuthenticationManager(ProviderManager 接口实现类) 认证管理器
        // 多种登录方式应该使用同一个认证管理器实例,所以获取 Spring 容器中已经存在的 AuthenticationManager 实例
        AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
        filter.setAuthenticationManager(authenticationManager);

        //(1.3) 设置 filter 使用自定义成功和失败处理器
        filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        //(1.4) 设置 filter 使用 SessionAuthenticationStrategy 会话管理器
        // 多种登录方式应该使用同一个会话管理器实例,获取 Spring 容器已经存在的 SessionAuthenticationStrategy 实例
        SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);
        filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);

        //(1.5) 在 UsernamePasswordAuthenticationFilter 过滤器之前添加 MobileCodeValidateFilter 过滤器
        // 在 UsernamePasswordAuthenticationFilter 过滤器之后添加 MobileAuthenticationFilter 过滤器
        http.addFilterBefore(mobileCodeValidaterFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);

        //(2) 将自定义的 MobileAuthenticationProvider 处理器绑定到 HttpSecurity 中
        //(2.1) 创建手机短信验证码认证过滤器的 AuthenticationProvider 实例,并指定所使用的 UserDetailsService
        MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);

        //(2.2) 将该 AuthenticationProvider 实例绑定到 HttpSecurity 中
        http.authenticationProvider(provider);
    }
}

?? 将上述自定义配置类 MobileAuthenticationConfig 绑定到最终的安全配置类 SpringSecurityConfig 中

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类
    //...
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 将手机短信验证码认证的配置与当前的配置绑定
        http.apply(mobileAuthenticationConfig);        
    }
    //...   
}

完整的安全配置类如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器

    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)

    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类

    /**
     * 密码编码器,密码不能明文存储
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用户认证管理器来实现用户认证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用内存存储方式,用户认证信息存储在内存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
                // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
                .loginPage("/login/page")
                // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
                .loginProcessingUrl("/login/form")
                // 设置登录表单中的用户名参数,默认为 username
                .usernameParameter("name")
                // 设置登录表单中的密码参数,默认为 password
                .passwordParameter("pwd")
                // 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
                //.defaultSuccessUrl("/index")
                // 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
                // 使用自定义的认证成功和失败处理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();

        // 关闭 csrf 防护
        http.csrf().disable();

        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);

        // 将手机短信验证码认证的配置与当前的配置绑定
        http.apply(mobileAuthenticationConfig);
    }

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

?? 测试

访问localhost:8080/mobile/page

手机号码输入11111111111,控制台输出:

向手机号 11111111111 发送短信:验证码为 6979,请勿泄露!

浏览器弹出窗口显示”发送成功“,输入正确短信验证码进行认证之后,浏览器重定向到/index