基于 antd pro 的短信验证码登录
概要
最近使用 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 之类的存储中