基于antd pro的企微验证码登录功能(前后端)
概述
企业微信作为一款即时办公通讯工具,在企业级应用中有很多功能场景,如:告警触达,数据推送,登录验证。其中登录验证方式,可以替换手机短信验证码登录方式,能够一定程度上为公司节约一笔费用支出。本文就登录验证这一功能,设计较为详细的一整套解决方案。
设计
登录界面设计:
大致交互设计:
- 登录账号,即域账户;密码为域账号密码,即开机密码。两者皆不可为空。前后端都需要拦截校验;
- 只有输入账号和密码后,才能点击获取验证码;即:点击获取验证码,需要拦截校验账号密码是否为空。为空,则不进入60s倒计时;不为空,则进入倒计时,并调用后端接口;
- 后端响应获取验证码,即getWechatCode,将验证码发送到用户的企微,验证码为4位随机数字
- 验证码不可为空,点击登录按钮前,前后端都需要校验;
- 登录成功,则跳转到首页;登录失败,弹窗展示失败信息;
实现
需求很明确,功能实现就不难。
登录界面是一个系统或者平台应用的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);
}
service类:
@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()));
}
}
@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()));
}
}