这一期我们来实现登录功能,登录实现方案比较多,下面给大家分析一下各种方案的优缺点。
常用的登录实现方案:
上面方案中我们首先把Session/Cookie
排除掉,下面我们从token+redis和jwt方案中选一个。
先看一下JWT方案的优点:
在我看来,JWT某些优点,对于后台管理系统的登录方案可能是缺点。做过后端管理系统的人应该知道,用户信息或权限可能会经常变更,如果使用JWT方案在用户权限变更后,没办法使已颁发的token失效,有的人说服务器存一个黑名单可以解决这个问题,这种其实就是有状态了,就不是去中心化了,那就失去了使用JWT的意义了,所以我们这个后台管理系统登录实现方案使用token+redis方案。个人觉得JWT适用论坛以及一些用户一旦注册后,信息就不会再变更的系统。
基本每个axios封装自动刷新token的文章下面都会有人说都搞自动刷新了,还不如用一个token,这里我说一下我的理解。先说明我打算使用双token这种方案,即一个普通token和一个用来刷新token的token。
使用双token主要还是从安全角度来说,如果是个人的小网站,不考虑安全的情况下是可以用单token的,甚至都可以在每个请求上都带上用户账号和密码,然后后端用账号密码做验证,这样连token都不需要了。
个人理解的双token的好处是:
access token每个请求都要求被携带,这样它暴露的概率会变大,但是它的有效期又很短,即使暴露了,也不会造成特别大的损失。而refresh token只有在access token失效的时候才会用到,使用的频率比access token低很多,所以暴露的概率也小一些。如果只使用一个token,要么把这个token的有效期设置的时间很长,要么动态在后端刷新,那如果这个token暴露后,别人可以一直用这个token干坏事,如果把这个token有效期设置很短,并且后端也不自动刷新,那用户可能用一会就要跳到登录页取登录一下,这样用户体验很差。所以本文采用双token的方式去实现登录。
实现登录功能之前,需要先实现用户增删改查功能,没有用户没办法登录。
node ./script/create-module user
// src/module/user/entity/user.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
import { omit } from 'lodash';
import { UserVO } from '../vo/user';
@Entity('sys_user')
export class UserEntity extends BaseEntity {
@Column({ comment: '用户名称' })
userName: string;
@Column({ comment: '用户昵称' })
nickName: string;
@Column({ comment: '手机号' })
phoneNumber: string;
@Column({ comment: '邮箱' })
email: string;
@Column({ comment: '头像', nullable: true })
avatar?: string;
@Column({ comment: '性别(0:女,1:男)', nullable: true })
sex?: number;
@Column({ comment: '密码' })
password: string;
toVO(): UserVO {
return omit<UserEntity>(this, ['password']) as UserVO;
}
}
启动项目后,因为typeorm配置里给自动同步打开了,实体会自动创建表和字段。
// src/module/user/dto/user.ts
import { ApiProperty } from '@midwayjs/swagger';
import { UserEntity } from '../entity/user';
import { BaseDTO } from '../../../common/base.dto';
import { Rule } from '@midwayjs/validate';
import { R } from '../../../common/base.error.util';
import {
email,
phone,
requiredString,
} from '../../../common/common.validate.rules';
export class UserDTO extends BaseDTO<UserEntity> {
@ApiProperty({ description: '用户名称' })
@Rule(requiredString.error(R.validateError('用户名称不能为空')))
userName: string;
@ApiProperty({ description: '用户昵称' })
@Rule(requiredString.error(R.validateError('用户昵称不能为空')))
nickName: string;
@ApiProperty({ description: '手机号' })
@Rule(phone.error(R.validateError('无效的手机号格式')))
phoneNumber: string;
@ApiProperty({ description: '邮箱' })
@Rule(email.error(R.validateError('无效的邮箱格式')))
email: string;
@ApiProperty({ description: '头像', nullable: true })
avatar?: string;
@ApiProperty({ description: '性别(0:女,1:男)', nullable: true })
sex?: number;
}
// src/module/user/service/user.ts
import { Provide } from '@midwayjs/decorator';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { omit } from 'lodash';
import { BaseService } from '../../../common/base.service';
import { UserEntity } from '../entity/user';
import { R } from '../../../common/base.error.util';
import { UserVO } from '../vo/user';
@Provide()
export class UserService extends BaseService<UserEntity> {
@InjectEntityModel(UserEntity)
userModel: Repository<UserEntity>;
getModel(): Repository<UserEntity> {
return this.userModel;
}
async create(entity: UserEntity): Promise<UserVO> {
const { userName, phoneNumber, email } = entity;
let isExist = (await this.userModel.countBy({ userName })) > 0;
if (isExist) {
throw R.error('当前用户名已存在');
}
isExist = (await this.userModel.countBy({ phoneNumber })) > 0;
if (isExist) {
throw R.error('当前手机号已存在');
}
isExist = (await this.userModel.countBy({ email })) > 0;
if (isExist) {
throw R.error('当前邮箱已存在');
}
// 添加用户的默认密码是123456,对密码进行加盐加密
const password = bcrypt.hashSync('123456', 10);
entity.password = password;
await this.userModel.save(entity);
// 把entity中的password移除返回给前端
return omit(entity, ['password']) as UserVO;
}
async edit(entity: UserEntity): Promise<void | UserVO> {
const { userName, phoneNumber, email, id } = entity;
let user = await this.userModel.findOneBy({ userName });
if (user && user.id !== id) {
throw R.error('当前用户名已存在');
}
user = await this.userModel.findOneBy({ phoneNumber });
if (user && user.id !== id) {
throw R.error('当前手机号已存在');
}
user = await this.userModel.findOneBy({ email });
if (user && user.id !== id) {
throw R.error('当前邮箱已存在');
}
await this.userModel.save(entity);
return omit(entity, ['password']) as UserVO;
}
}
这里说一下密码加盐的好处:
目前大部分系统都是用这种方案存储密码。
我们虽然可以使用swagger ui去测试,但是这个不太好用,推荐使用postman或Apifox,我这里使用Apifox。
使用Apifox新建一个项目,然后通过swagger把接口导入进去。
接口自动导进来了,我们测试一下新增用户接口:
查看数据库,数据已经插入进去了。
又新增了一条数据,虽然默认密码都是123456,但是数据库中存的是不一样的,这就是密码加盐的结果。
测试一下分页接口
import { t } from '@/utils/i18n';
import { Space, Table, Form, Row, Col, Input, Button, Popconfirm, App, Modal, FormInstance } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useAntdTable, useRequest } from 'ahooks'
import dayjs from 'dayjs'
import { useRef, useState } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import NewAndEditForm from './newAndEdit';
import userService, { User } from './service';
const UserPage = () => {
const [form] = Form.useForm();
const { message } = App.useApp();
const { tableProps, search: { submit, reset } } = useAntdTable(userService.getUserListByPage, { form });
const { runAsync: deleteUser } = useRequest(userService.deleteUser, { manual: true });
const [editData, setEditData] = useState<User | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
const formRef = useRef<FormInstance>(null);
const columns: ColumnsType<any> = [
{
title: t("qYznwlfj" /* 用户名 */),
dataIndex: 'userName',
},
{
title: t("gohANZwy" /* 昵称 */),
dataIndex: 'nickName',
},
{
title: t("yBxFprdB" /* 手机号 */),
dataIndex: 'phoneNumber',
},
{
title: t("XWVvMWig" /* 邮箱 */),
dataIndex: 'email',
},
{
title: t("ykrQSYRh" /* 性别 */),
dataIndex: 'sex',
render: (value: number) => value === 1 ? t("AkkyZTUy" /* 男 */) : t("yduIcxbx" /* 女 */),
},
{
title: t("TMuQjpWo" /* 创建时间 */),
dataIndex: 'createDate',
render: (value: number) => value && dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: t("QkOmYwne" /* 操作 */),
key: 'action',
render: (_, record) => record.userName !== 'admin' && (
<Space size="middle">
<a
onClick={() => {
setEditData(record);
setFormOpen(true);
}}
>{t("qEIlwmxC" /* 编辑 */)}</a>
<Popconfirm
title={t("JjwFfqHG" /* 警告 */)}
description={t("nlZBTfzL" /* 确认删除这条数据? */)}
onConfirm={async () => {
await deleteUser(record.id);
message.success(t("bvwOSeoJ" /* 删除成功! */));
submit();
}}
>
<a>{t("HJYhipnp" /* 删除 */)}</a>
</Popconfirm>
</Space>
),
},
];
const [formOpen, setFormOpen] = useState(false);
const openForm = () => {
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditData(null);
};
const saveHandle = () => {
submit();
setFormOpen(false);
setEditData(null);
}
return (
<div>
<Form onFinish={submit} form={form} size="large" className='dark:bg-[rgb(33,41,70)] bg-white p-[24px] rounded-lg'>
<Row gutter={24}>
<Col className='w-[100%]' lg={24} xl={8} >
<Form.Item name="nickName" label={t("rnyigssw" /* 昵称 */)}>
<Input onPressEnter={submit} />
</Form.Item>
</Col>
<Col className='w-[100%]' lg={24} xl={8}>
<Form.Item name="phoneNumber" label={t("SPsRnpyN" /* 手机号 */)}>
<Input onPressEnter={submit} />
</Form.Item>
</Col>
<Col className='w-[100%]' lg={24} xl={8}>
<Space>
<Button onClick={submit} type='primary'>{t("YHapJMTT" /* 搜索 */)}</Button>
<Button onClick={reset}>{t("uCkoPyVp" /* 清除 */)}</Button>
</Space>
</Col>
</Row>
</Form>
<div className="mt-[16px] dark:bg-[rgb(33,41,70)] bg-white rounded-lg px-[12px]">
<div className='py-[16px] '>
<Button onClick={openForm} type='primary' size='large' icon={<PlusOutlined />}>{t("morEPEyc" /* 新增 */)}</Button>
</div>
<Table
rowKey="id"
scroll={{ x: true }}
columns={columns}
className='bg-transparent'
{...tableProps}
/>
</div>
<Modal
title={editData ? t("wXpnewYo" /* 编辑 */) : t("VjwnJLPY" /* 新建 */)}
open={formOpen}
onOk={() => {
formRef.current?.submit();
}}
destroyOnClose
width={640}
zIndex={1001}
onCancel={closeForm}
confirmLoading={saveLoading}
>
<NewAndEditForm
ref={formRef}
editData={editData}
onSave={saveHandle}
open={formOpen}
setSaveLoading={setSaveLoading}
/>
</Modal>
</div>
);
}
export default UserPage;
这里我们底层请求工具是用axios,然后用ahooks里面的useRequest做接口请求管理,这个还挺好用的。简单的业务代码中,我基本很少使用useCallBack和useMemo,因为简单的页面使用这两个hooks做优化,性能提高不大。我也不推荐在业务代码中使用状态管理库,状态在本页面管理就行了,除非开发的功能很复杂需要跨功能共享数据,才使用状态管理库共享数据。这里先简单的实现一下功能,会面会把这个常用页面封装成组件,这样以后我们就可以快速开发一个crud页面了。axios也还没封装,后面再封装,封装的思路和代码也会分享给大家。
// src/pages/user/newAndEdit.tsx
import { t } from '@/utils/i18n';
import { Form, Input, Radio, App, FormInstance } from 'antd'
import { forwardRef, useImperativeHandle, ForwardRefRenderFunction } from 'react'
import userService, { User } from './service';
import { useRequest } from 'ahooks';
interface PropsType {
open: boolean;
editData?: any;
onSave: () => void;
setSaveLoading: (loading: boolean) => void;
}
const NewAndEditForm: ForwardRefRenderFunction<FormInstance, PropsType> = ({
editData,
onSave,
setSaveLoading,
}, ref) => {
const [form] = Form.useForm();
const { message } = App.useApp();
const { runAsync: updateUser } = useRequest(userService.updateUser, { manual: true });
const { runAsync: addUser } = useRequest(userService.addUser, { manual: true });
useImperativeHandle(ref, () => form, [form])
const finishHandle = async (values: User) => {
try {
setSaveLoading(true);
if (editData) {
await updateUser({ ...editData, ...values });
message.success(t("NfOSPWDa" /* 更新成功! */));
} else {
await addUser(values);
message.success(t("JANFdKFM" /* 创建成功! */));
}
onSave();
} catch (error: any) {
message.error(error?.response?.data?.message);
}
setSaveLoading(false);
}
return (
<Form
labelCol={{ sm: { span: 24 }, md: { span: 5 } }}
wrapperCol={{ sm: { span: 24 }, md: { span: 16 } }}
form={form}
onFinish={finishHandle}
initialValues={editData}
>
<Form.Item
label={t("qYznwlfj" /* 用户名 */)}
name="userName"
rules={[{
required: true,
message: t("jwGPaPNq" /* 不能为空 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("rnyigssw" /* 昵称 */)}
name="nickName"
rules={[{
required: true,
message: t("iricpuxB" /* 不能为空 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("SPsRnpyN" /* 手机号 */)}
name="phoneNumber"
rules={[{
required: true,
message: t("UdKeETRS" /* 不能为空 */),
}, {
pattern: /^(13[0-9]|14[5-9]|15[0-3,5-9]|16[2567]|17[0-8]|18[0-9]|19[89])\d{8}$/,
message: t("AnDwfuuT" /* 手机号格式不正确 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("XWVvMWig" /* 邮箱 */)}
name="email"
rules={[{
required: true,
message: t("QFkffbad" /* 不能为空 */),
}, {
pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
message: t("EfwYKLsR" /* 邮箱格式不正确 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("ykrQSYRh" /* 性别 */)}
name="sex"
initialValue={1}
>
<Radio.Group>
<Radio value={1}>{t("AkkyZTUy" /* 男 */)}</Radio>
<Radio value={0}>{t("yduIcxbx" /* 女 */)}</Radio>
</Radio.Group>
</Form.Item>
</Form>
)
}
export default forwardRef(NewAndEditForm);
// src/pages/user/service.ts
import axios from 'axios'
export interface User {
id: number;
userName: string;
nickName: string;
phoneNumber: string;
email: string;
createDate: string;
updateDate: string;
}
export interface PageData {
data: User[],
total: number;
}
const userService = {
// 分页获取用户列表
getUserListByPage: ({ current, pageSize }: { current: number, pageSize: number }, formData: any) => {
return axios.get<PageData>('/api/user/page', {
params: {
page: current - 1,
size: pageSize,
...formData,
}
}).then(({ data }) => {
return ({
list: data.data,
total: data.total,
})
})
},
// 添加用户
addUser: (data: User) => {
return axios.post('/api/user', data);
},
// 更新用户
updateUser: (data: User) => {
return axios.put('/api/user', data);
},
// 删除用户
deleteUser: (id: number) => {
return axios.delete(`/api/user/${id}`);
}
}
export default userService;
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import WindiCSS from 'vite-plugin-windicss'
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [
react(),
WindiCSS(),
],
resolve: {
alias: {
'@': '/src/',
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:7001',
changeOrigin: true,
}
}
}
})
node ./script/create-module auth
使用captcha
组件可以快速生成验证码图片,也支持验证码值验证。
pnpm i @midwayjs/captcha@3 --save
在 src/configuration.ts 中引入组件。
import * as captcha from '@midwayjs/captcha';
@Configuration({
imports: [
// ...other components
captcha
],
})
export class MainConfiguration {}
import {
Body,
Controller,
Inject,
Post,
Provide,
ALL,
Get,
} from '@midwayjs/decorator';
import { AuthService } from '../service/auth';
import { ApiResponse } from '@midwayjs/swagger';
import { TokenVO } from '../vo/token';
import { LoginDTO } from '../dto/login';
import { CaptchaService } from '../service/captcha';
import { R } from '../../../common/base.error.util';
@Provide()
@Controller('/auth')
export class AuthController {
@Inject()
authService: AuthService;
@Inject()
captchaService: CaptchaService;
@Get('/captcha')
async getImageCaptcha() {
const { id, imageBase64 } = await this.captchaService.formula({
height: 40,
width: 120,
noise: 1,
color: true,
});
return {
id,
imageBase64,
};
}
}
正常captchaService
是从captcha组件中导出来可以直接使用,我这里单独写了一个,是因为midway自带的captcha组件,不支持改文字颜色,导致生成的验证码在暗色主题下看不清,我就把代码拉了下来,改了一下,支持改文字颜色,过两天提个pr给midway。
用户登录时把账号密码和验证码相关信息传给后端,后端先验证码验证然后账号密码校验,校验成功后,生成两个token返回给前端,同时把这两个token存到redis中并且设置过期时间。
// src/module/auth/controller/auth.ts
import {
Body,
Controller,
Inject,
Post,
Provide,
ALL,
Get,
} from '@midwayjs/decorator';
import { AuthService } from '../service/auth';
import { ApiResponse } from '@midwayjs/swagger';
import { TokenVO } from '../vo/token';
import { LoginDTO } from '../dto/login';
import { CaptchaService } from '../service/captcha';
import { R } from '../../../common/base.error.util';
@Provide()
@Controller('/auth')
export class AuthController {
@Inject()
authService: AuthService;
@Inject()
captchaService: CaptchaService;
@Post('/login', { description: '登录' })
@ApiResponse({ type: TokenVO })
async login(@Body(ALL) loginDTO: LoginDTO) {
const { captcha, captchaId } = loginDTO;
const result = await this.captchaService.check(captchaId, captcha);
if (!result) {
throw R.error('验证码错误');
}
return await this.authService.login(loginDTO);
}
@Get('/captcha')
async getImageCaptcha() {
const { id, imageBase64 } = await this.captchaService.formula({
height: 40,
width: 120,
noise: 1,
color: true,
});
return {
id,
imageBase64,
};
}
}
// src/module/auth/service/auth.ts
import { Config, Inject, Provide } from '@midwayjs/decorator';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { UserEntity } from '../../user/entity/user';
import { R } from '../../../common/base.error.util';
import { LoginDTO } from '../dto/login';
import { TokenVO } from '../vo/token';
import { TokenConfig } from '../../../interface/token.config';
import { RedisService } from '@midwayjs/redis';
import { uuid } from '../../../utils/uuid';
@Provide()
export class AuthService {
@InjectEntityModel(UserEntity)
userModel: Repository<UserEntity>;
@Config('token')
tokenConfig: TokenConfig;
@Inject()
redisService: RedisService;
async login(loginDTO: LoginDTO): Promise<TokenVO> {
const { accountNumber } = loginDTO;
const user = await this.userModel
.createQueryBuilder('user')
.where('user.phoneNumber = :accountNumber', {
accountNumber,
})
.orWhere('user.username = :accountNumber', { accountNumber })
.orWhere('user.email = :accountNumber', { accountNumber })
.select(['user.password', 'user.id'])
.getOne();
if (!user) {
throw R.error('账号或密码错误!');
}
if (!bcrypt.compareSync(loginDTO.password, user.password)) {
throw R.error('用户名或密码错误!');
}
const { expire, refreshExpire } = this.tokenConfig;
const token = uuid();
const refreshToken = uuid();
// multi可以实现redis指令并发执行
await this.redisService
.multi()
.set(`token:${token}`, user.id)
.expire(`token:${token}`, expire)
.set(`refreshToken:${refreshToken}`, user.id)
.expire(`refreshToken:${refreshToken}`, refreshExpire)
.exec();
return {
expire,
token,
refreshExpire,
refreshToken,
} as TokenVO;
}
}
可以看到刚才我们传给后端的密码是明文的,这样是不安全的,所以我们需要给密码加密,即使被人拦截了,密码也不会泄漏。
我见过有人用base64给密码编码一下,这样做可以骗骗不懂技术的人,懂点技术的一些就破解了。
有人说前端加密是没用的,因为前端js是透明的,别人可以轻松知道你的加密方式,普通的加密方式确实是这样的,非对称加密可以解决这个问题。
非对称加密通俗一点的解释就是通过某种方法生成一对公钥和私钥,把公钥暴露出去给别人,私钥自己保存,别人用公钥加密的文本,然后用私钥把加密过后的文本解密出来。
如果使用固定的公钥和私钥,一旦私钥泄漏,所有人的密码都会受到威胁,这种方案安全性不高。我们使用动态的公钥和私钥,前端在登录的时候,先从后端获取一下公钥,后端动态生成公钥和私钥,公钥返回给前端,私钥存到redis中。前端拿到公钥后,使用公钥对密码加密,然后把公钥和加密过后的密码传给后端,后端通过公钥从redis中获取私钥去解密,解密成功后,把私钥从redis中删除。
现在没实现多少功能,所以暂时没有部署后端,后面实现功能多一点了,我就把后端部署一下,就可以让大家体验一下了。
阅读量:2009
点赞量:0
收藏量:0