连接真实后端
前面我们在utils/request.ts里面,网址前缀是空白的,这情况我们用request.ts连接后端api的时候,实际的网址是localhost:8000/… 也就是和前端的网址是一样的。我们在前端有一个mock模块,这个是用来模拟后端数据的。前后端分离的开发模式下当后端开发人员还没有把前端需要的api做出来的时候,经常用这种方式由前端开发人员先用模拟数据来测试效果。
修改utils/request.ts里面的网址前缀为sails后端的真实网址,就可以连接真实后端。(代码请自行修改,此处不再累述)
修改api.ts
src/services 文件夹里面有一个ant-design-pro文件夹,这个文件夹没有必要,为了目录更简洁,把里面的文件移出到src/services里面并删除ant-design-pro文件夹
本操作之后要原来对ant-design-pro文件夹里面的文件(import)引用的地方也需要改成新的文件路径
login
连接真实后端之后,我们需要修改登录页面在用户登录的时候请求的api,找到services/api.ts,修改登录请求如下:
export async function login(body: API.LoginParams, options?: { [key: string]: any }) {
console.log('[登录页面提交的body]:',body);//可以在这个地方下一个断点,看看登录界面都提交过来什么样的数据
body.email=body.username;//这个地方因为我们在sails里面是用email做key的,而登录界面提交过来的body里面没有email,对应的应该是username
return request<API.LoginResult>('/api/user/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
这个地方因为body有指定类型,我们没有办法执行body.email,会出如下错误:
为此我们需要修改LoginParams类型定义,导航到LoginParams定义,添加email属性
//LoginParams定义在同一级文件夹里面的typing.d.ts,修改如下:
type LoginParams = {
email?: string;
username?: string;
password?: string;
autoLogin?: boolean;
type?: string;
};
开始调试
接下来,我们需要理解前后端如何配合工作的,以及代码如何调试和优化。请按照以下步骤操作:
- 打开浏览器的登录界面->刷新->清空控制台信息->切换到network界面,清空network信息
- 再开一个vscode,打开sails源代码(sails源代码请同步github上面的最新版本)并启动sails(npm run dev),确定sails运行的端口和我们在前端的utils/request.ts里面写的url前缀一致。
- 用HeidiSql 查询user表,获取可以登录的email和密码(如果没有,请用postman执行api/userCreate 插入若干条用户记录),在登录界面上面输入可以登录的用户名和密码,点击登录按钮,如下图:
- 切换到sails后端源代码,查看UserController.ts里面login的最新源代码:
export async function login(req: Api.SailsRequest, res: Api.Response): Promise<any> {
let email = req.body.email, password = req.body.password;
if (!(email && password)) { return res.amis('email或password出现空值,请检查。', 401) }
//获取sails数据模型里面的user并且转换为UserModel.UserInstance类型
let wl = new wlSimulate(req._sails);
try {
let result = await wl.findOne('user', { email: req.body.email });
if (!result || !result.password) return res.amis(`找不到email为:${req.body.email}的用户。`, 402);
result.password = wl.decryptString(result.password);
if (result.password != password) return res.amis(`用户密码错误。`, 403);
let token = jwt.sign(
result.toJSON(),//需要调用toJSON转为简单对象(plain object)并去除密码,不能使用包含function的对象
sails.config.models.dataEncryptionKeys.default,
//sails.config.models.dataEncryptionKeys.default 采用config/models里面的数据加密密钥,避免多个密钥设置
{ expiresIn: '7d' }
//expiresIn 解释 1h:1小时 1m:1分钟 1s:1秒 1d:1天 100:100毫秒 详见:https://github.com/vercel/ms
);
return res.amis(token);
} catch (error) {
return res.serverError(error);
}
}
这个login函数以前已经了解过,其主要功能是根据前端提交过来的body里面的email和password 进行用户账号验证,如果通过验证,会用jwt技术返回一个token给前端。
此处源代码有做修改,主要修改两个:一是采用存储过程获取用户信息 二是调用amis 这个response中间件对返回给前端的信息进行统一格式封装,该中间件源代码在api/response/amis.ts文件中,代码请自行理解,目的是为了满足amis库对后端api的请求,具体要求文档如下:
https://aisuda.bce.baidu.com/amis/zh-CN/docs/types/api
修改login/index.tsx
现在我们可以看到,我们的登录按钮执行的api是sails真实返回的token了,但是我们的界面并没有像以前一样的进入系统管理页面,而是一动不动的停留在原地。我们还有许多工作要做。
因为我们的sails返回的内容和原来用mock模拟的时候返回的内容并不一样(有兴趣可以查看mock/user.ts里面的“POST /api/login/account”),src/pages/login/index.tsx这个页面(就是登录页面)对后端返回信息的处理是根据mock数据来的,现在变了,这个地方的源代码肯定也是要跟着改变的,观察login/index.tsx源代码,可以看到登录按钮按下去之后执行的onFinish函数里面主要是这一句:
await handleSubmit(values as API.LoginParams);
login/index.ts里面的LoginForm组件是ProComponents组件库,如果想要进一步的了解更多登录组件的信息,可以到下面地址查看文档和使用示例:
https://procomponents.ant.design/components/login-form
观察handleSubmit源代码会发现,它是根据api返回的status是否为‘ok’来判断是不是登录成功的,如下图:
观察sails源代码或是sails api返回的内容,我们发现正常登录返回的是status值为0(这个是为了和amis要求一致而做的)可以查阅sails源代码里面的api/responses/amis.ts中的封装代码,
因此,我们只要把这个地方改成if(msg.status===0) 就可以出现登录成功提示了。
这个修改可能导致类型错误,请自行修改LoginResult类型定义
保存修改,到登录界面重新登录,效果如下:
Jwt认证
可以登录成功,但是并没有跳转,为什么?观察控制台输出和network,可以看到并不是不能跳转,而是跳转到主界面的时候,好几个api都显示403 (Forbidden )禁止访问,比如:
http://localhost:1898/api/currentUser
如果到network界面观察,还可以发现后端返回的内容是:“您没有权限访问本页面”,这个一看就可以猜到是sails故意做的限制,可以思路一下为什么?然后再看接下来应该怎么做的。
- 为什么?sails要禁止访问,我们已经登录成功了呀?
登录成功进入管理界面,用户就可以通过api访问系统的敏感信息了,我们在sails端有讲解过JWT的技术,敏感信息的访问是需要在header里面添加token的,观察后端最新代码中config/policies.js 可以看到新的设置如下:
module.exports.policies = {
'*': 'authenticated',
'UserController': {
'login': true
}
};
这个配置的意思是,除了UserController控制器里面的login动作不需要authenticated之外,其它api全部需要认证。我们在前端只是修改了service/api.ts里面一个login的请求,其他的请求没有动,那那些请求必然是没有在header里面增加token的,没有token的访问在sails端被禁止了,所以当然是会返回403了。
- 保存token
那么考登录成功之后,我们可以获得token了,这个token就是登录后所有api访问时的令牌,它应该被保存在前端,并且可以在所有组件中共享。因为token的过期是后端设置的,前端不需要考虑过期问题,那么要保存并共享这个token只需要通过localStorage就可以解决了。
为此可以添加一个实现token保存和读取的功能,在utils里面添加myStorage.ts,代码如下:
export function saveToken(data: string) {
localStorage.setItem('token', data);
}
export function removeToken() {
localStorage.removeItem('token');
}
export function getToken() {
let res = localStorage.getItem('token') || '';
return res;
}
修改src/pages/user/login/index.tsx代码中,登录成功之后的动作,添加一个保存到localStorage的动作,部分代码如下:
...............
const handleSubmit = async (values: API.LoginParams) => {
try {
removeToken();
// 登录
let msg = await login({ ...values, type });
if (msg.status === 0) {
saveToken(msg.data.text);//如果登录成功,把返回的token保存到本地存储中
const defaultLoginSuccessMessage = intl.formatMessage({
id: 'pages.login.success',
defaultMessage: '登录成功!',
});
.................
以上修改,需要import { saveToken } from ‘@/utils/myStorage’; 并且需要修改LoginResult定义
修改保存后,打开浏览器的登录界面,重新按登录按钮,进入f12,单击Application按钮,找到LocalStorage,可以看到我们获取的token已经保存到本地存储中了。
- 设置header
保存完token之后,接下来的事情自然是设置每一个api请求的header(参考sails博文里面讲到的在postman里面设置token的测试)。当然如果每一个api请求都要去做相同的动作,那我们就可以通过umi-request的请求拦截器来实现,而不需要每次请求都要去添加代码。为此,修改utils/request.ts里面的代码,增加请求拦截器,代码片段如下:
/* eslint-disable */
import { extend, RequestOptionsInit } from 'umi-request';
import { getToken } from './myStorage';
const urlPrefix = 'http://localhost:1898';
const remoteRequest = extend({
prefix: urlPrefix,
timeout: 5000,
});
//请求拦截器,拦截每个请求,添加完token之后再发送到后端
remoteRequest.interceptors.request.use((url: string, options: RequestOptionsInit) => {
const headers = getToken()
? {
authorization: `Bearer ${getToken()}`,
}
: { authorization: ' ' };
return {
url,
options: { ...options, interceptors: true, headers: headers },
};
});
/**
* 读取本地文件
*/
export const localRequest = extend({
prefix: '',
timeout: 5000,
});
..........................
修改保存后,打开浏览器的登录界面,重新按登录按钮,进入f12,进入network,观察api请求头:比如http://localhost:1898/api/notices 这个api,可以看到Status Code为200 Method为Get的那个请求,其header里面已经带上token,并且请求成功了(原来是失败的),如下图:
获取当前登录的用户信息
然而我们依然不能成功跳转,原因是我们还有一个关键的请求是失败的:
http://localhost:1898/api/currentUser
这个请求是登录后获取用户信息(比如用户名称等)的api,请求失败原因是在service里面的api.ts中,这个请求不是sails里面真正的获取当前已经登录的用户信息的api,而是前期临时做的一个请求,修改api.ts里面sails后端正确的api地址即可:代码片段如下:
.......
export async function currentUser(options?: { [key: string]: any }) {
return request<{
data: API.CurrentUser;
}>('/api/user/currentUser', {
method: 'POST',
...(options || {}),
});
}
...............
保存修改后,可以成功跳转到管理界面了!但是有不完美,如下图:
右边原来显示用户名称和头像的地方一直处于loading状态,跟踪app.tsx源代码可知这个头像的参数设置是rightContentRender组件->Avatar组件->HeaderDropdown 里面的(src\components\RightContent\AvatarDropdown.tsx)代码截图如下:
这里显示这个Avatar的src参数是由currentUser.avatar,其中currentUser是从initialState来的,这个是一个全局共享的状态变量,useModel(‘@@initialState’);,这个状态数据的理解可以参考https://v3.umijs.org/zh-CN/plugins/plugin-initial-state。
回到app.tsx,观察app.tsx代码里面登录成功之后获取当前用户信息的这部分代码如下图:
F12界面观察Sails后端访问给前端的当前用户信息里面并没有avatar头像,我们可以修改后端代码,给一个当前用户头像,也可以直接在前端代码里面临时添加一个。如下:
- 修改app.tsx获取当前用户部分的代码如下:
.............
const fetchUserInfo = async () => {
try {
const msg = await queryCurrentUser();
msg.data.avatar = '/icons/userAvatar.png';//在返回数据里面添加avatar
msg.data.name = msg.data.email;//把返回的email当做当前用户明
return msg.data;
} catch (error) {
history.push(loginPath);
}
return undefined;
};
.............
- 在public\icons\文件夹里面添加 userAvatar.png头像文件
保存修改,刷新浏览器页面。头像和用户名称出来了,如下图:
至此,我们成功的实现了和Sails后端的通讯,实现真正的通过数据库对前端输入的用户email和密码进行验证的登录过程,并且登录成功之后,可以查询当前登录用户信息,以及对敏感api的Jwt保护性的访问。最终界面如下:
token的安全
登录功能几乎就要完成了,除了一点,token目前还是有隐患的。因为我们把token保存到本地。为此,我们可以做一个加密,把后端返回的token加密之后再保存到本地,然后需要写到请求头的时候从本地再解密。这样本地保存的token和写在请求头里面的token是不一样的,这样可以增加一点安全系数。
为此,我们需要安装一个前端加密解密的依赖库,如下:
npm install crypto-js@4.1.1 --save --legacy-peer-deps
安装之后,添加src\utils\dataEncryption.ts和src\utils\dtHelper.ts并编写代码如下:
//src\utils\dataEncryption.ts
import dtHelper from './dtHelper';
/**
* 数据加密库
*/
import CryptoJS from 'crypto-js';
class dataEncryption {
constructor() {
let key = `k${dtHelper.currentYear()}${dtHelper.currentQuarter()}`;
if (!this.dataEncryptionKeys[key]) key = 'default';
this.curKeyData = this.dataEncryptionKeys[key];
}
private curKeyData: string;
private dataEncryptionKeys = {
default: 'aGOSbyFccycxy9q0cj3ARc34rffcvX05V9isCClMy/U=',
k20224: 'dmppTBxXSlZ3JiyoR63KMOfGk8ngdUQQdc9frnx/gxI=',
k20231: 'MOHUweqYfOLpx5wNtj5hNTcLpOETbslHZ2mUNfg5yk4=',
k20232: 'F6NZaIQhoaCgltJ+C3qoXmQgOgFzu0kVQQ/SiA34LVc=',
k20233: 'JTucnT+/VmThhi98kuD8MyKWma6aOdV5a0DpKAZtWgE=',
k20234: 'aJE3jvXZf1eC9sPaGlOMZ14mRrst6ybCu0K0qSyE9Sg=',
};
/**
* 加密
*/
public encryptString(ps: string): string {
return CryptoJS.AES.encrypt(ps, this.curKeyData);
}
/**
* 解密
* @param tobeDecrypt
* @returns
*/
public decryptString(tobeDecrypt: string): string {
if (tobeDecrypt == '') return '';
// 解密数据
const bytes = CryptoJS.AES.decrypt(tobeDecrypt, this.curKeyData);
return bytes.toString(CryptoJS.enc.Utf8);
}
}
export default new dataEncryption();
//src\utils\dtHelper.ts
/**
* 日期时间格式工具
*/
class dtHelper {
/**
* 按照一定格式解析指定日期 默认格式{y}-{m}-{d} {h}:{i}:{s},其中M代表三位数的毫秒 a代表星期
* @param time 三种格式日期实际
* @param cFormat 格式代码例如:{y}-{m}-{d} {h}:{i}:{s}.{M}
* @returns
*/
static parseDateTime(time: Date | string | number, cFormat?: string): string {
if (arguments.length === 0 || !time) {
return '';
}
const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}.{M}';
let date;
if (typeof time === 'object') {
date = time;
} else {
if (typeof time === 'string') {
if (/^[0-9]+$/.test(time)) {
// support "1548221490638"
time = parseInt(time);
} else {
time = time.replace(new RegExp(/-/gm), '/');
}
}
if (typeof time === 'number' && time.toString().length === 10) {
time = time * 1000;
}
date = new Date(time);
}
const formatObj = {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
h: date.getHours(),
i: date.getMinutes(),
s: date.getSeconds(),
M: date.getMilliseconds(),
a: date.getDay(),
};
const time_str = format.replace(/{([ymdhisMa])+}/g, (result, key) => {
const value = formatObj[key];
// Note: getDay() returns 0 on Sunday
if (key === 'a') {
return ['日', '一', '二', '三', '四', '五', '六'][value];
}
return value.toString().padStart(2, '0');
});
return time_str;
}
/**
* 今年
* @returns 今年
*/
static currentYear(): number {
let year = new Date().getFullYear();
return year;
}
/**
* 本季度
* @returns 本季度
*/
static currentQuarter(): number {
let month = new Date().getMonth() + 1;
return month % 3 == 0 ? month / 3 : Math.ceil(month / 3);
}
}
export default dtHelper;
修改myStorage.ts代码如下:
import dataEncryption from './dataEncryption';
export function saveToken(data: string) {
data = dataEncryption.encryptString(data);//保存之前加密
localStorage.setItem('token', data);
}
export function removeToken() {
localStorage.removeItem('token');
}
export function getToken() {
let res = localStorage.getItem('token') || '';
res = dataEncryption.decryptString(res);//获取token后解密
return res;
}
保存,手动删除localStorage里面的token再刷新前端,重新登录后可以看到api的请求头里面的token和保存在localStorage里面的token不一样了。我们提高安全系数的行动成功了。
退出登录
这个操作比较简单,退出登录只要删除掉localStorage里面的token就可以了,因为我们全部的登录信息就只有一个token,因此修改service里面api.ts的退出登录代码如下:
import { removeToken } from '@/utils/myStorage';
/** 退出登录接口 POST /api/login/outLogin */
export async function outLogin(options?: { [key: string]: any }) {
removeToken();
}
保存并测试,可以看到退出登录后,localStorage里面的token没有了。