1、实例运行环境

[Tips:本文使用了阿里云提供的短信服务]

jdk1.8,aliyun-java-sdk-core-3.3.1、aliyun-java-sdk-dysmsapi-1.0.0

2、原理

我们通过调用手机短信验证码服务来发送短信验证码。

其中,验证码引擎(captchaEngine)用于生成验证码,验证码仓库用于储存验证码(captchaStore),手机短信验证码服务调用阿里云提供的API发送短信。

3、组件

验证码引擎

本类需要实现CaptchaEngine

import com.octo.captcha.Captcha;
import com.octo.captcha.CaptchaFactory;
import com.octo.captcha.engine.CaptchaEngine;
import com.octo.captcha.engine.CaptchaEngineException;
import com.octo.captcha.text.TextCaptcha;
import com.octo.captcha.text.TextCaptchaFactory;
import com.octo.captcha.text.math.MathCaptchaFactory;

import java.security.SecureRandom;
import java.util.*;

/**
 * SMS验证码引擎
 * 
 * @author hsdllcw
 * 
 */
public class SMSEngine implements CaptchaEngine {
   protected List factories = new ArrayList();
   protected Random myRandom = new SecureRandom();
   public static Integer digit;

   public SMSEngine(Integer digit){
      this.digit=digit;
   }
   /**
    * This method build a TextCaptchaFactory.
    *
    * @return a TextCaptchaFactory
    */
   public TextCaptchaFactory getTextCaptchaFactory() {
      return new SMSCaptchaFactory(digit);
   }

   /**
    * This return a new captcha.
    *
    * @return a new Captcha
    */
   public final TextCaptcha getNextTextCaptcha() {
      return getTextCaptchaFactory().getTextCaptcha();
   }

   /**
    * This return a new captcha. It may be used directly.
    *
    * @param locale the desired locale
    * @return a new Captcha
    */
   public TextCaptcha getNextTextCaptcha(Locale locale) {
      return getTextCaptchaFactory().getTextCaptcha(locale);
   }

   public final Captcha getNextCaptcha() {
      return getTextCaptchaFactory().getTextCaptcha();
   }

   /**
    * This return a new captcha. It may be used directly.
    *
    * @param locale the desired locale
    * @return a new Captcha
    */
   public Captcha getNextCaptcha(Locale locale) {
      return getTextCaptchaFactory().getTextCaptcha(locale);
   }


   /**
    * @return captcha factories used by this engine
    */
   public CaptchaFactory[] getFactories() {
      return (CaptchaFactory[]) this.factories.toArray(new CaptchaFactory[factories.size()]);
   }

   /**
    * @param factories new captcha factories for this engine
    */
   public void setFactories(CaptchaFactory[] factories) throws CaptchaEngineException {
      checkNotNullOrEmpty(factories);
      ArrayList tempFactories = new ArrayList();

      for (int i = 0; i < factories.length; i++) {
         if (!MathCaptchaFactory.class.isAssignableFrom(factories[i].getClass())) {
            throw new CaptchaEngineException("This factory is not an text captcha factory " + factories[i].getClass());
         }
         tempFactories.add(factories[i]);
      }

      this.factories = tempFactories;
   }

   protected void checkNotNullOrEmpty(CaptchaFactory[] factories) {
      if (factories == null || factories.length == 0) {
         throw new CaptchaEngineException("impossible to set null or empty factories");
      }
   }
}

事实上,验证码是通过验证码工厂生成的,所以我们还需要一个短信验证码工厂

import com.octo.captcha.CaptchaQuestionHelper;
import com.octo.captcha.text.TextCaptcha;
import com.octo.captcha.text.TextCaptchaFactory;

import java.util.Locale;
import com.octo.captcha.text.math.MathCaptcha;

/**
 * 短信验证码<br/> <b>Do not use this in production!!!</b>
 *
 * @author hsdllcw
 * @version 1.0
 */
public class SMSCaptchaFactory extends TextCaptchaFactory {

    private static final String BUNDLE_QUESTION_KEY = MathCaptcha.class.getName();

    public static Integer digit;

    public SMSCaptchaFactory(Integer digit) {
        this.digit=digit;
    }

    /**
     * 短信验证码.
     *
     * @return 一个短信验证码
     */
    public TextCaptcha getTextCaptcha() {
        return getTextCaptcha(Locale.getDefault());
    }

    /**
     * 一个短信验证码.
     *
     * @return 一个本地化的短信验证码.
     */
    public TextCaptcha getTextCaptcha(Locale locale) {
        //生成验证码
        StringBuffer code=new StringBuffer();
        for(int i=0;i<digit;i++)
        {
            code.append(((int)(Math.random()*10)));
        }
        TextCaptcha captcha = new SMSCaptcha(getQuestion(locale), code.toString(), String.valueOf(code));

        return captcha;
    }

    protected String getQuestion(Locale locale) {
        return CaptchaQuestionHelper.getQuestion(locale, BUNDLE_QUESTION_KEY);
    }


}

关于digit参数:此参数代表短信验证码的长度。稍后我们将在spring中注册。

在验证码工厂中,验证码被储存到SMSCaptcha对象里。实际上SMSCaptcha是短信验证码的本体。我们看看SMSCaptcha的实现。

/*
 * JCaptcha, the open source java framework for captcha definition and integration
 * Copyright (c)  2007 jcaptcha.net. All Rights Reserved.
 * See the LICENSE.txt file distributed with this package.
 */

import com.octo.captcha.text.TextCaptcha;

/**
 * <p>Simple math captcha</p>
 *
 * @author <a href="mailto:marc.antoine.garrigue@gmail.com">Marc-Antoine Garrigue</a>
 * @version 1.0
 */
public class SMSCaptcha extends TextCaptcha {

    private String response;

    SMSCaptcha(String question, String challenge, String response) {
        super(question, challenge);
        this.response = response;
    }

    /**
     * Validation routine from the CAPTCHA interface. this methods verify if the response is not null and a String and
     * then compares the given response to the internal string.
     *
     * @return true if the given response equals the internal response, false otherwise.
     */
    public final Boolean validateResponse(final Object response) {
        return (null != response && response instanceof String)
                ? validateResponse((String) response) : Boolean.FALSE;
    }

    /**
     * Very simple validation routine that compares the given response to the internal string.
     *
     * @return true if the given response equals the internal response, false otherwise.
     */
    private final Boolean validateResponse(final String response) {
        return Boolean.valueOf(response.equals(this.response));
    }
}

此时,验证码引擎就完成了。我们将在短信验证码服务里调用验证码引擎,获取它生成的验证码,然后再使用阿里云的短信api将生成的验证码发送出去。

短信验证码服务

import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySendDetailsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySendDetailsResponse;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.octo.captcha.Captcha;
import com.octo.captcha.engine.CaptchaEngine;
import com.octo.captcha.service.CaptchaServiceException;
import com.octo.captcha.service.captchastore.CaptchaStore;
import com.octo.captcha.service.text.TextCaptchaService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.text.SimpleDateFormat;
import java.util.*;

/**
 * SMSCaptchaService
 * 
 * @author hsdllcw
 * 
 */
@Service
public class SMSCaptchaService implements TextCaptchaService {
    public static String product;
    public static String domain;
    public static  String accessKeyId;
    public static  String accessKeySecret;
    public static  String signName;
    public static  String templateCode;
    public static String isDev;

    protected CaptchaStore store;
    protected CaptchaEngine engine;
    protected Logger logger;

    @SuppressWarnings("all")
    public SMSCaptchaService(
            CaptchaStore captchaStore,
            SMSEngine captchaEngine,
            String product,
            String domain,
            String accessKeyId,
            String accessKeySecret,
            String signName,
            String templateCode,
            String isDev
    ){
        if (captchaEngine == null || captchaStore == null)
            throw new IllegalArgumentException("Store or gimpy can't be null");
        this.engine = captchaEngine;
        this.store = captchaStore;
        this.product=product;
        this.domain=domain;
        this.accessKeyId=accessKeyId;
        this.accessKeySecret=accessKeySecret;
        this.signName=signName;
        this.templateCode=templateCode;
        this.isDev=isDev;
        logger = LoggerFactory.getLogger(this.getClass());

        logger.info("Init " + this.store.getClass().getName());
        this.store.initAndStart();
    }


    public  Map<String,Object> sendSms(String phoneNumber,String code) {
        Map<String,Object> data=new HashMap<String,Object>();
        //超时时间
        System.setProperty("sun.net.client.defaultConnectTimeout", "10000");
        System.setProperty("sun.net.client.defaultReadTimeout", "10000");

        try {

            //初始化acsClient,暂不支持region化
            IClientProfile profile = DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret);
            DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", product, domain);
            IAcsClient acsClient = new DefaultAcsClient(profile);
            //组装参数
            SendSmsRequest request = new SendSmsRequest();
            request.setPhoneNumbers(phoneNumber);
            request.setSignName(signName);
            request.setTemplateCode(templateCode);
            request.setTemplateParam("{\"code\":\""+code+"\"}");
            SendSmsResponse sendSmsResponse = acsClient.getAcsResponse(request);
            String statusOK="OK";
            if(sendSmsResponse.getCode() != null && sendSmsResponse.getCode().equals("OK")){
                data.put("status",true);
                data.put("msg",code);
            }else {
                data.put("status",false);
                data.put("msg","未知错误");
                QuerySendDetailsResponse querySendDetailsResponse = querySendDetails(sendSmsResponse.getBizId(),phoneNumber);
                System.out.println("短信明细查询接口返回数据----------------");
                System.out.println("Code=" + querySendDetailsResponse.getCode());
                System.out.println("Message=" + querySendDetailsResponse.getMessage());
                int i = 0;
                for(QuerySendDetailsResponse.SmsSendDetailDTO smsSendDetailDTO : querySendDetailsResponse.getSmsSendDetailDTOs())
                {
                    System.out.println("SmsSendDetailDTO["+i+"]:");
                    System.out.println("Content=" + smsSendDetailDTO.getContent());
                    System.out.println("ErrCode=" + smsSendDetailDTO.getErrCode());
                    System.out.println("OutId=" + smsSendDetailDTO.getOutId());
                    System.out.println("PhoneNum=" + smsSendDetailDTO.getPhoneNum());
                    System.out.println("ReceiveDate=" + smsSendDetailDTO.getReceiveDate());
                    System.out.println("SendDate=" + smsSendDetailDTO.getSendDate());
                    System.out.println("SendStatus=" + smsSendDetailDTO.getSendStatus());
                    System.out.println("Template=" + smsSendDetailDTO.getTemplateCode());
                }
                System.out.println("TotalCount=" + querySendDetailsResponse.getTotalCount());
                System.out.println("RequestId=" + querySendDetailsResponse.getRequestId());
            }
        }catch (ClientException e){
            data.put("status",false);
            data.put("msg",e.getErrMsg());
        }
        return data;
    }

    public QuerySendDetailsResponse querySendDetails(String bizId,String phoneNumber) throws ClientException {

        //可自助调整超时时间
        System.setProperty("sun.net.client.defaultConnectTimeout", "10000");
        System.setProperty("sun.net.client.defaultReadTimeout", "10000");

        //初始化acsClient,暂不支持region化
        IClientProfile profile = DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret);
        DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", product, domain);
        IAcsClient acsClient = new DefaultAcsClient(profile);

        //组装请求对象
        QuerySendDetailsRequest request = new QuerySendDetailsRequest();
        //必填-号码
        request.setPhoneNumber(phoneNumber);
        //可选-流水号
        request.setBizId(bizId);
        //必填-发送日期 支持30天内记录查询,格式yyyyMMdd
        SimpleDateFormat ft = new SimpleDateFormat("yyyyMMdd");
        request.setSendDate(ft.format(new Date()));
        //必填-页大小
        request.setPageSize(10L);
        //必填-当前页码从1开始计数
        request.setCurrentPage(1L);

        //hint 此处可能会抛出异常,注意catch
        QuerySendDetailsResponse querySendDetailsResponse = acsClient.getAcsResponse(request);

        return querySendDetailsResponse;
    }

    @Override
    public String getTextChallengeForID(String ID) throws CaptchaServiceException {
        return (String)this.getChallengeForID(ID);
    }

    @Override
    public String getTextChallengeForID(String ID, Locale locale) throws CaptchaServiceException {
        return (String) this.getChallengeForID(ID, locale);
    }

    public String getTextChallengeForID(String ID, Locale locale,String phoneNumber) throws CaptchaServiceException {
        if("true".equals(isDev)){
            return sendSms(phoneNumber,this.getTextChallengeForID(ID, locale)).get("status").toString();
        }else {
            System.out.println(this.getTextChallengeForID(ID, locale));
            return "true";
        }
    }

    @Override
    public Object getChallengeForID(String ID) throws CaptchaServiceException {
        return this.getChallengeForID(ID, Locale.getDefault());
    }

    @Override
    public Object getChallengeForID(String ID, Locale locale) throws CaptchaServiceException {
        Captcha captcha;
        Object challenge;
        //check if has capthca
        if (!this.store.hasCaptcha(ID)) {
            //if not generate and store
            captcha = generateAndStoreCaptcha(locale, ID);
        } else {
            //else get it
            captcha = this.store.getCaptcha(ID);
            if (captcha == null) {
                captcha = generateAndStoreCaptcha(locale, ID);
            } else {
                //if dirty
                if (captcha.hasGetChalengeBeenCalled().booleanValue()) {
                    //get a new one and store it
                    captcha = generateAndStoreCaptcha(locale, ID);
                }
                //else nothing
            }
        }
        challenge = getChallengeClone(captcha);
        captcha.disposeChallenge();

        return challenge;
    }

    @Override
    public String getQuestionForID(String ID) throws CaptchaServiceException {
        return this.getQuestionForID(ID, Locale.getDefault());
    }

    public String getQuestionForID(String ID, Locale locale) throws CaptchaServiceException {
        Captcha captcha;
        //check if has capthca
        if (!this.store.hasCaptcha(ID)) {
            //if not generate it
            captcha = generateAndStoreCaptcha(locale, ID);
        } else {
            captcha = this.store.getCaptcha(ID);
            if (captcha == null) {
                captcha = generateAndStoreCaptcha(locale, ID);
            }else if (locale != null) {
                Locale storedlocale = this.store.getLocale(ID);
                if (!locale.equals(storedlocale)) {
                    captcha = generateAndStoreCaptcha(locale, ID);
                }
            }

        }
        return captcha.getQuestion();
    }

    @Override
    public Boolean validateResponseForID(String ID, Object response)
            throws CaptchaServiceException {
        if (!store.hasCaptcha(ID)) {
            throw new CaptchaServiceException("Invalid ID, could not validate unexisting or already validated captcha");
        } else {
            Boolean valid = store.getCaptcha(ID).validateResponse(response);
            store.removeCaptcha(ID);
            return valid;
        }
    }

    protected Captcha generateAndStoreCaptcha(Locale locale, String ID) {
        Captcha captcha = engine.getNextCaptcha(locale);
        this.store.storeCaptcha(ID, captcha, locale);
        return captcha;
    }

    protected Object getChallengeClone(Captcha captcha) {
        return new StringBuilder(captcha.getChallenge().toString()).toString();
    }

    public Boolean tryResponseForID(String ID, Object response, boolean removeOnError) throws CaptchaServiceException {
        if (!store.hasCaptcha(ID)) {
            throw new CaptchaServiceException("Invalid ID, could not validate unexisting or already validated captcha");
        } else {
            Boolean valid = store.getCaptcha(ID).validateResponse(response);
            if (removeOnError) {
                store.removeCaptcha(ID);
            }
            return valid;
        }
    }
}

至此,整个组件均已经完成,接下来我们需要在spring注册组件

<!-- 验证码仓库 -->
<bean id="captchaStore" class="com.octo.captcha.service.captchastore.FastHashMapCaptchaStore" scope="prototype"/>
<!-- 短信验证码引擎 -->
<bean id="smsEngine" class="com.jspxcms.common.captcha.SMSEngine">
   <constructor-arg index="0" value="4"/>
</bean>
<!-- 短信验证码Service -->
<bean id="smsCaptchaService" class="你的包.SMSCaptchaService">
   <constructor-arg index="0" ref="captchaStore"/>
   <constructor-arg index="1" ref="smsEngine"/>
   <constructor-arg index="2" type="java.lang.String" value="${SMS.product}"/>
   <constructor-arg index="3" type="java.lang.String" value="${SMS.domain}"/>
   <constructor-arg index="4" type="java.lang.String" value="${kqadmin.SMS.AccessKeyId}"/>
   <constructor-arg index="5" type="java.lang.String" value="${kqadmin.SMS.AccessKeySecret}"/>
   <constructor-arg index="6" type="java.lang.String" value="${SMS.SignName}"/>
   <constructor-arg index="7" type="java.lang.String" value="${SMS.TemplateCode}"/>
   <constructor-arg index="8" type="java.lang.String" value="${SMS.dev}"/>
</bean>

4、验证码的校验

我们需要一个验证码校验工具

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.octo.captcha.service.CaptchaService;
import com.octo.captcha.service.CaptchaServiceException;

/**
 * 验证码工具类
 * 
 * @author hsdllcw
 * 
 */
public abstract class Captchas {
   protected static final Logger logger = LoggerFactory
         .getLogger(Captchas.class);

   public static boolean isValid(CaptchaService service,
         HttpServletRequest request, String captcha) {
      HttpSession session = request.getSession(false);
      if (session == null) {
         return false;
      }
      try {
         return service.validateResponseForID(session.getId(), captcha);
      } catch (CaptchaServiceException e) {
         logger.warn("captcha exception", e);
         return false;
      }
   }

   public static boolean isValidTry(MyImageCaptchaService service,
         HttpServletRequest request, String captcha) {
      return isValidTry(service, request, captcha, false);
   }

   public static boolean isValidTry(CaptchaService service,
         HttpServletRequest request, String captcha, boolean removeOnError) {
      if (StringUtils.isBlank(captcha)) {
         return false;
      }
      HttpSession session = request.getSession(false);
      if (session == null) {
         return false;
      }
      try {
         if(service instanceof MyImageCaptchaService){
            return ((MyImageCaptchaService)service).tryResponseForID(session.getId(), captcha,
                  removeOnError);
         }else{
            return ((SMSCaptchaService)service).tryResponseForID(session.getId(), captcha,
                  removeOnError);
         }
      } catch (CaptchaServiceException e) {
         logger.warn("captcha exception", e);
         return false;
      }
   }
}

那么,这里是如何实现短信验证码在不同用户中的正确校验呢?比如用户A的短信验证码为1111,用户B的短信验证码为2222,此时用户A向服务器发送了2222,那么我们是如何知道2222来自A用户并且验证码的正确与否?我们看验证码工具类的代码,看到校验方法里有一个参数是HttpServletRequest,我们通过这个参数就可以获知那个用户的sessionid,在Java web服务器里,sessionid是唯一的,所以我们可以通过sessionid辨认用户,再用sessionid验证当前用户的验证码正确与否。

校验工具里还包含了图片验证码的的校验过程,可以根据需要自行删除。