7

前言

目前接触最多的登录方式是使用用户名和密码进行登录,现在尝试写了使用阿里云短信通道完成手机验证码登录,参考历史上老师和学长写过的代码,将基本流程进行完成。

准备工作

首先参考阿里云官方文档进行准备工作
image.png
本文方便后续统一更改,将这些信息放到了application中进行配置。

在该配置中,可以配置使用不同的服务,目前先使用本地测试,待基本逻辑打通后便可以改成其他方式进行测试。
image.png

我的项目暂时使用阿里云 Java SDK 的核心功能,所以添加这个 Maven 依赖来引入 SDK。

<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-core</artifactId>
    <version>4.0.3</version>
</dependency>

短信服务工厂与实现类

采用工厂模式 + 策略模式 ,短信服务选择流程如下:

graph LR
    A[配置文件读取] --> B{short-message.type}
    B -->|ali| C[阿里云短信服务]
    B -->|local| D[本地控制台服务]
    
    C --> E[读取阿里云配置<br/>access-key-id, access-secret等]
    E --> F[构建阿里云客户端]
    F --> G[发送真实短信]
    
    D --> H[直接打印到日志]
    
    G --> I[异步返回发送结果]
    H --> I
    
    subgraph 工厂模式
        J[ShortMessageServiceFactory]
        K[自动发现所有实现类]
        L[建立type->service映射]
    end
    
    A --> J
    K --> L
    J --> M[根据type获取服务实例]

策略模式:

抽象策略接口

/**
 * 短信服务接口
 */
public interface ShortMessageService {
    /**
     * 获取当前验证码的实现类型
     */
    Short getType();

    /**
     * 发送验证码
     *
     * @param phoneNumber 手机号(仅支持大陆手机号)
     * @param code        验证码
     */
    void sendValidateCode(String phoneNumber, String code);

    void sendValidateCode(String phoneNumber);
}

多个可互换的策略实现

  1. 本地控制台策略
/**
 * 本地打印短信服务实现类
 */
@Service
public class ConsoleShortMessageServiceImpl implements ShortMessageService {
    private static final Logger logger = LoggerFactory.getLogger(ConsoleShortMessageServiceImpl.class);

    @Override
    public Short getType() {
        return ShortMessageType.local.getCode();
    }

    @Override
    public void sendValidateCode(String phoneNumber, String code) {
        Assert.isTrue(Utils.isMobile(phoneNumber), "传入的手机号格式不正确");
        logger.info("目标手机号: {}, 验证码: {}", phoneNumber, code);
    }

    @Override
    public void sendValidateCode(String phoneNumber) {
        this.sendValidateCode(phoneNumber, Utils.generateRandomNumberCode(4));
    }
}

本地打印:
image.png

  1. 阿里云短信策略
    创建阿里云客户端、构造请求、填充模板、并发送短信,并且会把错误输出日志
@Service
public class AliShortMessageServiceImpl implements ShortMessageService {
    private static final Logger logger = LoggerFactory.getLogger(AliShortMessageServiceImpl.class);

    private final ShortMessageProperties shortMessageProperties;

    public AliShortMessageServiceImpl(ShortMessageProperties shortMessageProperties) {
        this.shortMessageProperties = shortMessageProperties;
    }

    @Override
    public Short getType() {
        return ShortMessageType.ali.getCode();
    }

    /**
     * 发送验证码
     * @param phoneNumber 手机号(仅支持大陆手机号)
     * @param code        验证码
     */
    @Async
    @Override
    public void sendValidateCode(String phoneNumber, String code) {
        JsonObject jsonObject = new JsonObject();
        jsonObject.addProperty("code", code);
        this.sendShortMessage(jsonObject, phoneNumber, this.shortMessageProperties.getTemplateId());
    }

    @Async
    @Override
    public void sendValidateCode(String phoneNumber) {
        this.sendValidateCode(phoneNumber, Utils.generateRandomNumberCode(4));
    }

    private void sendShortMessage(JsonObject jsonObject, String phoneNumber, String templateCode) {
        // 校验手机号格式
        Assert.isTrue(Utils.isMobile(phoneNumber), "传入的手机号格式不正确");

        // 创建阿里云通信客户端,连接阿里云短信服务器的客户端
        DefaultProfile profile = DefaultProfile.getProfile(
                this.shortMessageProperties.getRegionId(),
                this.shortMessageProperties.getAccessKeyId(),
                this.shortMessageProperties.getAccessSecret());

        IAcsClient client = new DefaultAcsClient(profile);
        
        // 构建一个短信请求对象
        CommonRequest request = new CommonRequest();
        request.setMethod(MethodType.POST);
        request.setDomain(this.shortMessageProperties.getDomain());
        request.setAction("SendSms");
        request.setVersion("2017-05-25");
        request.putQueryParameter("RegionId", this.shortMessageProperties.getRegionId());
        request.putQueryParameter("PhoneNumbers", phoneNumber);
        request.putQueryParameter("SignName", this.shortMessageProperties.getSignName());
        request.putQueryParameter("TemplateCode", templateCode);
        request.putQueryParameter("TemplateParam", jsonObject.toString());
        try {
            CommonResponse response = client.getCommonResponse(request);
            Gson gson = new Gson();
            JsonObject jsonResponse = gson.fromJson(response.getData(), JsonObject.class);
            if (!jsonResponse.get("Code").getAsString().equals("OK")) {
                logger.error(phoneNumber + "发送短信发生错误:" + response.getData());
            }

        } catch (ServerException e) {
            logger.error(String.format("验证码发送发生服务端错误:%s,手机号:%s,内容:%s", e.getMessage(), phoneNumber, jsonObject.toString()));
            e.printStackTrace();
            throw new RuntimeException("验证码发送失败(服务端错误)", e);
        } catch (ClientException e) {
            logger.error(String.format("验证码发送发生客户端错误:%s,手机号:%s,内容:%s", e.getMessage(), phoneNumber, jsonObject.toString()));
            e.printStackTrace();
            throw new RuntimeException("验证码发送失败(客户端错误)", e);
        }
    }
}

阿里云发送模式:image.png

工厂模式:

短信服务工厂类用于统一管理并选择短信发送策略。
通过扫描所有短信实现类,将其按照类型映射到一个 Map 中,并根据配置文件或传入参数返回对应的短信发送实现类。
/**
 * 短信服务工厂类
 */
@Component
public class ShortMessageServiceFactory {
    private static final Logger logger = LoggerFactory.getLogger(ShortMessageServiceFactory.class);

    private final Short smsTypeValue;

    private final Map<Short, ShortMessageService> serviceMap;

    public ShortMessageServiceFactory(ShortMessageProperties shortMessageProperties,
                                      List<ShortMessageService> shortMessageServices) {
        this.serviceMap = shortMessageServices.stream()
                .collect(Collectors.toConcurrentMap(
                        ShortMessageService::getType,
                        Function.identity(),
                        (existing, replacement) -> {
                            logger.warn("发现重复的 ShortMessageService type: {}, 保留第一个", existing.getType());
                            return existing;
                        }
                ));
        this.smsTypeValue = shortMessageProperties.getType().getCode();
        logger.info("短信服务工厂完成初始化,默认类型:{}", smsTypeValue);
    }

    /**
     * 根据配置文件获取默认类型的service
     */
    public ShortMessageService getDefaultService() {
        return serviceMap.get(this.smsTypeValue);
    }

    public ShortMessageService getService(short type) {
        ShortMessageService service = serviceMap.get(type);
        if (service == null) {
            throw new RuntimeException("不支持的短信类型:" + type);
        }
        return service;
    }
}

发送验证码与登录实现

需要前后台进行对接,时序图如下:

sequenceDiagram
participant U as 用户
participant C as Controller
participant V as ValidationService
participant F as ServiceFactory
participant S as SmsService
participant Cache as 缓存

U->>C: 1. 请求发送验证码(手机号)
C->>V: 2. 调用sendCode(手机号)
V->>V: 3. 验证手机号格式
V->>V: 4. 检查发送频率
V->>V: 5. 生成4位随机码
V->>F: 6. 获取短信服务
F-->>V: 7. 返回短信服务实例
V->>S: 8. 异步发送短信
V->>Cache: 9. 缓存验证码
V-->>C: 10. 返回成功
C-->>U: 11. 收到成功响应

Note over S,Cache: 并行执行: 发送短信和缓存验证码

U->>C: 12. 提交登录(手机号+验证码)
C->>V: 13. 调用validateCode
V->>Cache: 14. 查询缓存
Cache-->>V: 15. 返回验证码信息
V->>V: 16. 验证有效期和次数
V-->>C: 17. 返回验证结果
C->>C: 18. 根据结果处理登录逻辑
C-->>U: 19. 返回登录结果
            

请求手机发送验证码及收到响应

涉及到手机验证码的安全问题,我们增加一个CodeCache,一个验证码的小型生命周期管理器。

主要解决以下问题:
1.防止同一个手机号在短时间内疯狂发送验证码(限制发送频率)
2.防止验证码无限试错(限制用户尝试次数)
3.验证码必须过期(安全要求)
4.对每一个手机号保存单独的验证码状态(需要一个容器)
public static class CodeCache {
    // 验证码
    private String code;

    // 存入的时间
    private Calendar time;

    /**
     * 被获取的次数
     * 验证码每被获取1次,该值加1
     */
    private int getCount = 0;

    public CodeCache(String code) {
        this(code, Calendar.getInstance());
    }

    public CodeCache(String code, Calendar time) {
        this.code = code;
        this.time = time;
    }

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

    public void setCode(String code) {
        this.code = code;
    }

    public Calendar getTime() {
        return this.time;
    }

    public void setTime(Calendar time) {
        this.time = time;
    }

    boolean isEffective(int effectiveTimes) {
        if (this.time == null) {
            return false;
        }

        return Math.abs(this.time.getTimeInMillis() - Calendar.getInstance().getTimeInMillis()) <= effectiveTimes;
    }

    /**
     * 校验码是否有效
     *
     * @param effectiveTimes 有效时间
     * @param maxGetCount    最大获取次数
     */
    boolean isEffective(int effectiveTimes, int maxGetCount) {
        if (this.getCount >= maxGetCount) {
            return false;
        }
        return this.isEffective(effectiveTimes);
    }

    /**
     * 校验码是否过期
     *
     * @param expiredTimes 过期时间
     * @param maxGetTimes  最大获取次数
     */
    public boolean isExpired(int expiredTimes, int maxGetTimes) {
        return !this.isEffective(expiredTimes, maxGetTimes);
    }
}

发送短信对接前台:

image.png

@PostMapping("sendCode")
public void sendCode(@RequestBody ShortMessageDto.SendCodeRequest request) {
    this.validationCodeService.sendCode(request.getPhone());
}
public String sendCode(String phoneNumber) {
    Assert.isTrue(Utils.isMobile(phoneNumber), "电话号码格式不正确");
    if (!this.validateSendInterval(phoneNumber)) {
        throw new CallingIntervalIllegalException(String.format("该手机号%s发送频率过于频繁", phoneNumber));
    }
    String code = Utils.generateRandomNumberCode(this.codeLength);

    // 调用该方法,在工厂类判断是那种方式,如果是local,则本地调用发送,如果是ali,则执行实际发送短信逻辑
    this.shortMessageService.sendValidateCode(phoneNumber, code);
    this.cacheData.put(phoneNumber, new CodeCache(code));
    return code;
}

private boolean validateSendInterval(String phoneNumber) {
    if (!this.cacheData.containsKey(phoneNumber)) {
        return true;
    }

    return !this.cacheData.get(phoneNumber).isEffective(this.minSendInterval);
}

登录功能:

前台需要传入手机号和获取到的验证码:

/**
  * 根据手机验证码进行登录
  */
loginBySms(): void {
    const payload = {
      phone: this.formGroup.get('phone')?.value,
      code: this.formGroup.get('code')?.value
    };

    this.userService.loginBySms(payload).pipe(takeUntil(this.ngOnDestroy$))
      .subscribe({
        next: () => {
          this.errorInfo.set([]);
          this.router.navigate(['/']).then();
        }
      });
  }

手机号验证码进行登录相当于是免密登录:

@PostMapping("/loginBySms")
@JsonView(LoginBySmsJsonView.class)
public User loginBySms(@RequestBody ShortMessageDto.LoginBySmsRequest loginBySmsRequest,
                       HttpServletRequest request) {

    String phone = loginBySmsRequest.getPhone();
    String code = loginBySmsRequest.getCode();

    // 1. 校验验证码
    boolean valid = this.validationCodeService.validateCode(phone, code);
    if (!valid) {
        throw new ValidationException("验证码错误或已过期");
    }

    // 2.根据手机号查询该手机号是否与用户进行绑定
    User user = this.userRepository.findByPhoneAndDeletedIsFalse(phone)
            .orElseThrow(() -> new ValidationException("该手机号未绑定用户,请联系管理员"));

    // 3.通过用户构建 Authentication
    // 手机验证码相当于是免密登录
    // 只要能提供一个合法的 Authentication,它就认为你登录了。
    UsernamePasswordAuthenticationToken authToken =
            new UsernamePasswordAuthenticationToken(
                    user,
                    null,  // 没有密码
                    user.getAuthorities()
            );

    // 4.创建 SecurityContext 并设置认证信息
    // Spring Security 每次请求都是从 SecurityContext 里取“当前登录用户”
    SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
    securityContext.setAuthentication(authToken);

    // 5.将 SecurityContext 存入 session
    request.getSession(true).setAttribute("SPRING_SECURITY_CONTEXT", securityContext);

    return user;
}

检验验证码是否有效,主要做以下事情:

  1. 判断是否为空,为空则无效
  2. 检验缓存中是否存在该手机号,不存在则无效
  3. 获取验证码,判断验证码是否过期或获取次数过多
  4. 如果验证码相等则通过,验证成功后立即删除,防止二次使用
/**
 * 校验验证码是否有效
 *
 * @param key  键
 * @param code 验证码
 */
@Override
public boolean validateCode(String key, String code) {
    // 判断是否为空,为空则无效
    if (code == null) {
        return false;
    }

    // 检验缓存中是否存在该手机号,不存在则无效
    if (!this.cacheData.containsKey(key)) {
        return false;
    }

    CodeCache codeCache = this.cacheData.get(key);

    // 判断验证码是否过期或获取次数过多
    if (codeCache.isExpired(this.expiredTimes, this.maxGetCount)) {
        this.cacheData.remove(key);
        return false;
    }

    this.clearCacheRandom();

    if (code.equals(codeCache.getCode())) {
        this.cacheData.remove(key); // 验证成功后立即删除
        return true;
    }
    return false;
}

至此,手机验证码登录功能基本已经实现。

结语

感谢老师和学长提供的学习环境,当团队中存在示例后作为小白的我们学起来才会显示轻松一点。通过阅读本文,可以简单了解到利用手机验证码登录的一些知识,如果存在问题,欢迎指出!


李子轩xuan
66 声望11 粉丝