- A+
所属分类:Web前端
概要
最近使用 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 之类的存储中