博客 / 詳情

返回

Angular + SpringBoot 簡單實現手機驗證碼登錄功能

前言

目前接觸最多的登錄方式是使用用户名和密碼進行登錄,現在嘗試寫了使用阿里雲短信通道完成手機驗證碼登錄,參考歷史上老師和學長寫過的代碼,將基本流程進行完成。

準備工作

首先參考阿里雲官方文檔進行準備工作
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;
}

至此,手機驗證碼登錄功能基本已經實現。

結語

感謝老師和學長提供的學習環境,當團隊中存在示例後作為小白的我們學起來才會顯示輕鬆一點。通過閲讀本文,可以簡單瞭解到利用手機驗證碼登錄的一些知識,如果存在問題,歡迎指出!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.