概要
最近使用 antd pro 開發項目時遇到個新的需求, 就是在登錄界面通過短信驗證碼來登錄, 不使用之前的用戶名密碼之類登錄方式.
這種方式雖然增加了額外的短信費用, 但是對於安全性確實提高了不少. antd 中並沒有自帶能夠倒計時的按鈕,
但是 antd pro 的 ProForm components 中倒是提供了針對短信驗證碼相關的組件.
組件說明可參見: https://procomponents.ant.design/components/form
整體流程
通過短信驗證碼登錄的流程很簡單:
- 請求短信驗證碼(客戶端)
- 生成短信驗證碼, 並設置驗證碼的過期時間(服務端)
- 調用短信接口發送驗證碼(服務端)
- 根據收到的短信驗證碼登錄(客戶端)
- 驗證手機號和短信驗證碼, 驗證通過之后發行 jwt-token(服務端)
前端
頁面代碼
1 import React, { useState } from 'react';
2 import { connect } from 'umi';
3 import { message } from 'antd';
4 import ProForm, { ProFormText, ProFormCaptcha } from '@ant-design/pro-form';
5 import { MobileTwoTone, MailTwoTone } from '@ant-design/icons';
6 import { sendSmsCode } from '@/services/login';
7
8 const Login = (props) => {
9 const [countDown, handleCountDown] = useState(5);
10 const { dispatch } = props;
11 const [form] = ProForm.useForm();
12 return (
13 <div
14 style={{
15 width: 330,
16 margin: 'auto',
17 }}
18 >
19 <ProForm
20 form={form}
21 submitter={{
22 searchConfig: {
23 submitText: '登錄',
24 },
25 render: (_, dom) => dom.pop(),
26 submitButtonProps: {
27 size: 'large',
28 style: {
29 width: '100%',
30 },
31 },
32 onSubmit: async () => {
33 const fieldsValue = await form.validateFields();
34 console.log(fieldsValue);
35 await dispatch({
36 type: 'login/login',
37 payload: { username: fieldsValue.mobile, sms_code: fieldsValue.code },
38 });
39 },
40 }}
41 >
42 <ProFormText
43 fieldProps={{
44 size: 'large',
45 prefix: <MobileTwoTone />,
46 }}
47 name="mobile"
48 placeholder="請輸入手機號"
49 rules={[
50 {
51 required: true,
52 message: '請輸入手機號',
53 },
54 {
55 pattern: new RegExp(/^1[3-9]\d{9}$/, 'g'),
56 message: '手機號格式不正確',
57 },
58 ]}
59 />
60 <ProFormCaptcha
61 fieldProps={{
62 size: 'large',
63 prefix: <MailTwoTone />,
64 }}
65 countDown={countDown}
66 captchaProps={{
67 size: 'large',
68 }}
69 name="code"
70 rules={[
71 {
72 required: true,
73 message: '請輸入驗證碼!',
74 },
75 ]}
76 placeholder="請輸入驗證碼"
77 onGetCaptcha={async (mobile) => {
78 if (!form.getFieldValue('mobile')) {
79 message.error('請先輸入手機號');
80 return;
81 }
82 let m = form.getFieldsError(['mobile']);
83 if (m[0].errors.length > 0) {
84 message.error(m[0].errors[0]);
85 return;
86 }
87 let response = await sendSmsCode(mobile);
88 if (response.code === 10000) message.success('驗證碼發送成功!');
89 else message.error(response.message);
90 }}
91 />
92 </ProForm>
93 </div>
94 );
95 };
96
97 export default connect()(Login);
請求驗證碼和登錄的 service (src/services/login.js)
1 import request from '@/utils/request';
2
3 export async function login(params) {
4 return request('/api/v1/login', {
5 method: 'POST',
6 data: params,
7 });
8 }
9
10 export async function sendSmsCode(mobile) {
11 return request(`/api/v1/send/smscode/${mobile}`, {
12 method: 'GET',
13 });
14 }
處理登錄的 model (src/models/login.js)
1 import { stringify } from 'querystring';
2 import { history } from 'umi';
3 import { login } from '@/services/login';
4 import { getPageQuery } from '@/utils/utils';
5 import { message } from 'antd';
6 import md5 from 'md5';
7
8 const Model = {
9 namespace: 'login',
10 status: '',
11 loginType: '',
12 state: {
13 token: '',
14 },
15 effects: {
16 *login({ payload }, { call, put }) {
17 payload.client = 'admin';
18 // payload.password = md5(payload.password);
19 const response = yield call(login, payload);
20 if (response.code !== 10000) {
21 message.error(response.message);
22 return;
23 }
24
25 // set token to local storage
26 if (window.localStorage) {
27 window.localStorage.setItem('jwt-token', response.data.token);
28 }
29
30 yield put({
31 type: 'changeLoginStatus',
32 payload: { data: response.data, status: response.status, loginType: response.loginType },
33 }); // Login successfully
34
35 const urlParams = new URL(window.location.href);
36 const params = getPageQuery();
37 let { redirect } = params;
38
39 console.log(redirect);
40 if (redirect) {
41 const redirectUrlParams = new URL(redirect);
42
43 if (redirectUrlParams.origin === urlParams.origin) {
44 redirect = redirect.substr(urlParams.origin.length);
45
46 if (redirect.match(/^\/.*#/)) {
47 redirect = redirect.substr(redirect.indexOf('#') + 1);
48 }
49 } else {
50 window.location.href = '/home';
51 }
52 }
53 history.replace(redirect || '/home');
54 },
55
56 logout() {
57 const { redirect } = getPageQuery(); // Note: There may be security issues, please note
58
59 window.localStorage.removeItem('jwt-token');
60 if (window.location.pathname !== '/user/login' && !redirect) {
61 history.replace({
62 pathname: '/user/login',
63 search: stringify({
64 redirect: window.location.href,
65 }),
66 });
67 }
68 },
69 },
70 reducers: {
71 changeLoginStatus(state, { payload }) {
72 return {
73 ...state,
74 token: payload.data.token,
75 status: payload.status,
76 loginType: payload.loginType,
77 };
78 },
79 },
80 };
81 export default Model;
后端
后端主要就 2 個接口, 一個處理短信驗證碼的發送, 一個處理登錄驗證
路由的代碼片段:
1 apiV1.POST("/login", authMiddleware.LoginHandler)
2 apiV1.GET("/send/smscode/:mobile", controller.SendSmsCode)
短信驗證碼的處理
短信驗證碼的處理有幾點需要注意:
- 生成隨機的固定長度的數字
- 調用短信接口發送驗證碼
- 保存已經驗證碼, 以備驗證用
生成固定長度的數字
以下代碼生成 6 位的數字, 隨機數不足 6 位前面補 0
1 r := rand.New(rand.NewSource(time.Now().UnixNano()))
2 code := fmt.Sprintf("%06v", r.Int31n(1000000))
調用短信接口
這個簡單, 根據購買的短信接口的說明調用即可
保存已經驗證碼, 以備驗證用
這里需要注意的是驗證碼要有個過期時間, 不能一個驗證碼一直可用.
臨時存儲的驗證碼可以放在數據庫, 也可以使用 redis 之類的 KV 存儲, 這里為了簡單, 直接在內存中使用 map 結構來存儲驗證碼
1 package util
2
3 import (
4 "fmt"
5 "math/rand"
6 "sync"
7 "time"
8 )
9
10 type loginItem struct {
11 smsCode string
12 smsCodeExpire int64
13 }
14
15 type LoginMap struct {
16 m map[string]*loginItem
17 l sync.Mutex
18 }
19
20 var lm *LoginMap
21
22 func InitLoginMap(resetTime int64, loginTryMax int) {
23 lm = &LoginMap{
24 m: make(map[string]*loginItem),
25 }
26 }
27
28 func GenSmsCode(key string) string {
29 r := rand.New(rand.NewSource(time.Now().UnixNano()))
30 code := fmt.Sprintf("%06v", r.Int31n(1000000))
31
32 if _, ok := lm.m[key]; !ok {
33 lm.m[key] = &loginItem{}
34 }
35
36 v := lm.m[key]
37 v.smsCode = code
38 v.smsCodeExpire = time.Now().Unix() + 600 // 驗證碼10分鍾過期
39
40 return code
41 }
42
43 func CheckSmsCode(key, code string) error {
44 if _, ok := lm.m[key]; !ok {
45 return fmt.Errorf("驗證碼未發送")
46 }
47
48 v := lm.m[key]
49
50 // 驗證碼是否過期
51 if time.Now().Unix() > v.smsCodeExpire {
52 return fmt.Errorf("驗證碼(%s)已經過期", code)
53 }
54
55 // 驗證碼是否正確
56 if code != v.smsCode {
57 return fmt.Errorf("驗證碼(%s)不正確", code)
58 }
59
60 return nil
61 }
登錄驗證
登錄驗證的代碼比較簡單, 就是先調用上面的 CheckSmsCode 方法驗證是否合法.
驗證通過之后, 根據手機號獲取用戶信息, 再生成 jwt-token 返回給客戶端即可.
FAQ
antd 版本問題
使用 antd pro 的 ProForm 要使用 antd 的最新版本, 最好 >= v4.8, 否則前端組件會有不兼容的錯誤.
可以優化的點
上面實現的比較粗糙, 還有以下方面可以繼續優化:
- 驗證碼需要控制頻繁發送, 畢竟發送短信需要費用
- 驗證碼直接在內存中, 系統重啟后會丟失, 可以考慮放在 redis 之類的存儲中