基于antd pro的企微验证码登录功能(前后端)

10 篇文章 0 订阅

概述

企业微信作为一款即时办公通讯工具,在企业级应用中有很多功能场景,如:告警触达,数据推送,登录验证。其中登录验证方式,可以替换手机短信验证码登录方式,能够一定程度上为公司节约一笔费用支出。本文就登录验证这一功能,设计较为详细的一整套解决方案。

设计

登录界面设计:
在这里插入图片描述
大致交互设计:

  1. 登录账号,即域账户;密码为域账号密码,即开机密码。两者皆不可为空。前后端都需要拦截校验;
  2. 只有输入账号和密码后,才能点击获取验证码;即:点击获取验证码,需要拦截校验账号密码是否为空。为空,则不进入60s倒计时;不为空,则进入倒计时,并调用后端接口;
  3. 后端响应获取验证码,即getWechatCode,将验证码发送到用户的企微,验证码为4位随机数字
  4. 验证码不可为空,点击登录按钮前,前后端都需要校验;
  5. 登录成功,则跳转到首页;登录失败,弹窗展示失败信息;

实现

需求很明确,功能实现就不难。

登录界面是一个系统或者平台应用的first sight,需要给用户留一个好的印象。

前端

antd中并没有自带能够倒计时的按钮,但antd pro的ProForm components中倒是提供针对验证码相关的组件ProFormCaptcha。

前端代码:
service.ts文件:

export async function getWechatCode(params: any) {
  return request('/api/login/wechatCode', {
    method: 'POST',
    data: params,
  });
}

export async function accountLogin(params: LoginParamsType2) {
  return request('/api/login/login', {
    method: 'POST',
    data: params,
  });
}

model.ts文件:

import { stringify } from 'querystring';
import { history, Reducer, Effect } from 'umi';
import { accountLogin } from '@/services/login';
import { setAuthority, setCookie } from '@/utils/authority';
import { getPageQuery } from '@/utils/utils';
import { message } from 'antd';

export interface StateType {
  status?: 'ok' | 'error';
  type?: string;
  logMsg?: string;
  currentAuthority?: 'user' | 'guest' | 'admin';
}

export interface LoginModelType {
  namespace: string;
  state: StateType;
  effects: {
    login: Effect;
  };
  reducers: {
    changeLoginStatus: Reducer<StateType>;
    setLoginErrorMsg: Reducer<StateType>;
  };
}

const Model: LoginModelType = {
  namespace: 'login',

  state: {
    status: undefined,
  },

  effects: {
    *login({ payload, callback }, { call, put, select }) {
      const response = yield call(accountLogin, {...payload});
      yield put({
        type: 'changeLoginStatus',
        payload: response,
      });
      // Login successfully
      if (response.status === 'success') {
        setCookie("token", response.data?.jwtToken);
        const urlParams = new URL(window.location.href);
        const params = getPageQuery();
        message.success('登录成功!');
        let { redirect } = params as { redirect: string };
        if (redirect) {
          const redirectUrlParams = new URL(redirect);
          if (redirectUrlParams.origin === urlParams.origin) {
            redirect = redirect.substr(urlParams.origin.length);
            if (redirect.match(/^\/.*#/)) {
              redirect = redirect.substr(redirect.indexOf('#') + 1);
            }
          } else {
            window.location.href = '/';
            return;
          }
        }
        history.replace(redirect || '/');
      } else if (response.status === 'error' && callback) {
        callback()
      }
    },
  },

  reducers: {
    changeLoginStatus(state, { payload }) {
      setAuthority(payload.currentAuthority);
      return {
        ...state,
        status: payload.status,
        logMsg: payload.msg,
        type: 'account',
      };
    },
  },
};

export default Model;

Login.tsx文件,其中核心代码为throw new Error("获取验证码错误");

import React, {useState} from "react";
import {Form, message} from 'antd';
import {connect, Dispatch, FormattedMessage, useIntl} from "umi";
import type {StateType} from '@/models/login';
import ProForm, {ProFormCaptcha, ProFormText} from '@ant-design/pro-form';
import type {ConnectState} from '@/models/connect';
import type {LoginParamsType2} from "@/services/login";
import {getWechatCode} from "@/services/login";
import styles from './index.less';
import {PasswordIcon, UserIdIcon, WechatCodeIcon} from './svg/svg';

interface LoginProps {
  dispatch: Dispatch;
  userLogin: StateType;
  submitting?: boolean;
}

const LoginMessage: React.FC<{
  content: string;
}> = ({content}) => (
  <div className={styles.loginMessage}>
    {content}
  </div>
);

const Login: React.FC<LoginProps> = (props) => {
  const {userLogin = {}, submitting} = props;
  const {status, type: loginType,} = userLogin;
  const [type] = useState<string>('account');
  const intl = useIntl();
  const [form] = Form.useForm();
  const [wechatCodeInfo, setWechatCodeInfo] = useState<any>({})
  const handleSubmit = (values: LoginParamsType2) => {
    const {dispatch} = props;
    dispatch({
      type: 'login/login',
      payload: {...values, type},
      callback: () => {
        dispatch({
          type: 'imagecode/getImgCode',
        })
      }
    })
  };

  return (
    <div className={styles.main}>

      <ProForm
        form={form}
        initialValues={{
          autoLogin: true,
        }}
        submitter={{
          render: (_, dom) => dom.pop(),
          submitButtonProps: {
            loading: submitting,
            size: 'large',
            style: {
              width: '100%',
              background: '#3F81EE',
              borderColor: '#3F81EE',
            },
          },
          searchConfig: {
            submitText: '登录',
          },
        }}
        onFinish={async (values) => {
          handleSubmit(values);
        }}
      >

        {status === 'error' && (
          <LoginMessage
            content={userLogin.logMsg ?? ''}
          />
        )}
        {wechatCodeInfo.status === 'error' && (
          <LoginMessage
            content={wechatCodeInfo.msg ?? ''}
          />
        )}
        {type === 'account' && (
          <>
            <ProFormText
              name="loginName"
              fieldProps={{
                size: 'large',
                prefix: <UserIdIcon className={styles.prefixIcon}/>,
              }}
              placeholder={intl.formatMessage({
                id: 'pages.login.username.placeholder',
                defaultMessage: '请输入账号',
              })}
              rules={[
                {
                  required: true,
                  message: (
                    <FormattedMessage
                      id="pages.login.username.required"
                      defaultMessage="请输入账号!"
                    />
                  ),
                },
              ]}
            />
            <ProFormText.Password
              name="password"
              fieldProps={{
                size: 'large',
                prefix: <PasswordIcon className={styles.prefixIcon}/>,
              }}
              placeholder={intl.formatMessage({
                id: 'pages.login.password.placeholder',
                defaultMessage: '请输入您的密码',
              })}
              rules={[
                {
                  required: true,
                  message: (
                    <FormattedMessage
                      id="pages.login.password.required"
                      defaultMessage="请输入密码!"
                    />
                  ),
                },
              ]}
            />
            <ProFormCaptcha
              name="wechatCode"
              fieldProps={{
                size: 'large',
                prefix: <WechatCodeIcon className={styles.prefixIcon}/>,
              }}
              captchaProps={{
                size: 'small',
              }}
              placeholder={intl.formatMessage({
                id: 'pages.login.wechatCode.placeholder',
                defaultMessage: '请输入企微验证码',
              })}
              captchaTextRender={(timing, count) => {
                if (timing) {
                  return `${count} 获取验证码`;
                }
                return '获取验证码';
              }}
              rules={[
                {
                  required: true,
                  message: (
                    <FormattedMessage
                      id="pages.login.wechatCode.required"
                      defaultMessage="请输入企微验证码!"
                    />
                  ),
                },
                {
                  pattern: /^\d{4}$/,
                  message: '请输入4位数字企微验证码!',
                },
              ]}
              onGetCaptcha={async () => {
                if (!form.getFieldValue('loginName') || !form.getFieldValue('password')) {
                  message.error('请输入用户名密码');
                  // 满足拦截条件时,不触发60s倒计时
                  throw new Error("获取验证码错误");
                  return;
                }
                const values = form.getFieldsValue();
                getWechatCode({
                  loginName: values.loginName,
                  password: values.password,
                }).then((res: any) => {
                  setWechatCodeInfo(res)
                })
              }}
            />
          </>
        )}

      </ProForm>
    </div>
  );
}

export default connect(({login, loading}: ConnectState) => ({
  userLogin: login,
  submitting: loading.effects['login/login'],
}))(Login);

后端

Controller类,个人认为不应该有任何数据校验逻辑,应该足够轻量化:

@RequestMapping("wechatCode")
public String getWechatCode(@RequestBody JSONObject jsonObject) {
    return userService.getWechatCode(jsonObject);
}

@RequestMapping("login")
public String login(@RequestBody JSONObject jsonObject) {
    return userService.login(jsonObject);
}

发送验证码接口,前端传参loginName和password。主要逻辑:

  1. 前端传参非空校验
  2. 用户是否是数据库合法记录
  3. 用户名密码能否通过LDAP域检查
  4. 检查通过,则生成随机验证码,将验证码缓存到Redis,过期时间1分钟
  5. 验证码发送到用户企微
@Override
public String getWechatCode(JSONObject jsonObject) {
    String loginName = jsonObject.getString("loginName");
    String password = jsonObject.getString("password");
    if (StringUtils.isEmpty(loginName) || StringUtils.isEmpty(password)) {
        return JSONObject.toJSONString(ServiceUtil.returnError("用户名或密码不能为空!"));
    }
    try {
        // 判断用户是否存在
        DashboardUser user = userMapper.findUserByName(loginName);
        if (user == null) {
            return JSONObject.toJSONString(ServiceUtil.returnError(Constant.USERNAME_PASSWORD_ERR_MSG));
        }
        // 判断密码是否正确
        if (adminUserId.equals(user.getUserId()) || 1 == user.getUserType()) {
            // 普通用户
            if (!StringUtil.getMd5(password).equals(user.getUserPassword())) {
                return JSONObject.toJSONString(ServiceUtil.returnError(Constant.USERNAME_PASSWORD_ERR_MSG));
            }
        } else {
            // 域用户
            Boolean status = HttpUtil.checkDomain("CORP\\" + loginName, password, domainIp, domainPort);
            if (!status) {
                return JSONObject.toJSONString(ServiceUtil.returnError(Constant.USERNAME_PASSWORD_ERR_MSG));
            }
        }
        // 生成4位随机数字验证码并放入缓存中,缓存key是用户名_login_valid_qiwei,value是验证码,有效期1分钟
        String wechatCode = DataUtil.getRandomNum(4);
        log.info("4位随机数字验证码是:{}", wechatCode);
        String wechatKey = loginName.concat("_login_valid_qiwei");
        RedisTool.setKeyValueExpire(jedisCluster, wechatKey, wechatCode, 60);
        // 发送验证码到用户企微
        SendWeChatUtil.sendWeChat("你的账号正在尝试登录http://report.teslacorp.com,验证码:" + wechatCode + ",有效期1分钟,请及时处理。", user.getUserQiwei());
        return JSONObject.toJSONString(ServiceUtil.returnSuccess(wechatCode));
    } catch (Exception e) {
        return JSONObject.toJSONString(ServiceUtil.returnError(e.getMessage()));
    }
}

登录接口,前端传参:

  • username:用户名,可以是普通账户,也可以是域账户;
  • password:密码;
  • weChatCode:用户收到的企微验证码

主要有如下逻辑:

  1. 前端传参不为空检查
  2. 验证码不能是过期的,即必须和缓存里设置的带有过期时间的验证码相等
  3. 用户是否存在,是否是user表里的有效记录
  4. 用户名密码登录LDAP域检查
  5. 登录成功,则查询其权限role信息
  6. 生成jwt token
@Override
public String login(JSONObject jsonObject) {
    String wechatCode = jsonObject.getString("wechatCode");
    String loginName = jsonObject.getString("loginName");
    String password = jsonObject.getString("password");
    if (StringUtils.isEmpty(wechatCode)) {
		return JSONObject.toJSONString(ServiceUtil.returnError("企微验证码为空,请重试"));
    }
    if (StringUtils.isEmpty(loginName) || StringUtils.isEmpty(password)) {
        return JSONObject.toJSONString(ServiceUtil.returnError("用户名或密码不能为空!"));
    }
    // 验证企业微信验证码
    String wechatKey = loginName.concat("_login_valid_qiwei");
    try {
        String cacheWechatCode = RedisTool.getValueByKey(jedisCluster, wechatKey);
        // 验证企业微信验证码是否过期
        if (StringUtils.isEmpty(cacheWechatCode)) {
			return JSONObject.toJSONString(ServiceUtil.returnError("企微验证码过期,请重试"));
        }
        // 验证企业微信验证码是否正确
        if(!cacheWechatCode.equals(wechatCode)){
            return JSONObject.toJSONString(ServiceUtil.returnError("企微验证码错误,请重试"));
        }
        RedisTool.delKey(jedisCluster,wechatKey);
    } catch (Exception e) {
        return JSONObject.toJSONString(ServiceUtil.returnError(e.getMessage()));
    } finally {
        RedisTool.delKey(jedisCluster, wechatKey);
    }
    try {
        // 判断用户是否存在
        DashboardUser user = userMapper.findUserByName(loginName);
        if (user == null) {
            return JSONObject.toJSONString(ServiceUtil.returnError(Constant.USERNAME_PASSWORD_ERR_MSG));
        }
        // 普通用户
        if (adminUserId.equals(user.getUserId()) || 1 == user.getUserType()) {
            if (!StringUtil.getMd5(password).equals(user.getUserPassword())) {
                return JSONObject.toJSONString(ServiceUtil.returnError(Constant.USERNAME_PASSWORD_ERR_MSG));
            }
        } else {
            // 域用户
            Boolean status = HttpUtil.checkDomain("CORP\\" + loginName, password, domainIp, domainPort);
            if (!status) {
                return JSONObject.toJSONString(ServiceUtil.returnError(Constant.USERNAME_PASSWORD_ERR_MSG));
            }
        }
        // 登录成功
        List<DashboardRole> roleList = dashboardRoleResMapper.getRoleIdByUserId(user.getUserId());
        List<String> roleIds = roleList.stream().map(DashboardRole::getRoleId).collect(Collectors.toList());
        List<String> roleNames = roleList.stream().map(DashboardRole::getRoleName).collect(Collectors.toList());
        String jwtToken = JwtUtil.createJwt(user.getUserName(), user.getLoginName(), user.getUserId(), clientId, roleIds, roleNames,
                name, expiresSecond * 1000L, base64Secret);
        JSONObject resultJson = new JSONObject();
        resultJson.put("jwtToken", jwtToken);
        return JSONObject.toJSONString(ServiceUtil.returnSuccessData(resultJson));
    } catch (Exception e) {
        return JSONObject.toJSONString(ServiceUtil.returnError(e.getMessage()));
    }
}

进阶

注:本来我们的这款企业级内部平台应用是采用滑动验证码登录方案,后来应部门要求,所有内部应用风格统一化,如都加上watermark水印(纯前端改造优化),登录界面的登录功能统一为发送企微验证码。

回顾一下上面的后端代码,login接口本来是已有的,在此基础之上增加一个getWechatCode接口,这个改造是另一个所谓资深开发写的。

仅仅分析这两个接口,不难发现有很多重复的逻辑,参数校验就不说了。检查前端传参用户名是否是数据库的合法记录,以及检查LDAP域合法性都是可以复用的代码。可复用的代码应该抽取出来,比如放在一个private bool checkUser(String username, String password)方法里。

参考

JWT入门教程
LDAP实战之登录验证、用户查询、获取全量有效用户及邮件组

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

johnny233

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值