这一期我们来实现登录功能,登录实现方案比较多,下面给大家分析一下各种方案的优缺点。
常用的登录实现方案:
上面方案中我们首先把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中删除。




现在没实现多少功能,所以暂时没有部署后端,后面实现功能多一点了,我就把后端部署一下,就可以让大家体验一下了。
阅读量:2117
点赞量:0
收藏量:0