Nest项目(六)-鉴权优化,并配合前端实现登录和注册

790 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第14天,点击查看活动详情

前言

继续整理一下当前项目的情况,已满足:

  • 启动一个服务,端口为 3000
  • 可以处理get、post请求,操作数据库并返回自定义数据结构
  • 可以通过接口访问经过 ejs 编译后的 html,并按照 ejs 的规则实现数据渲染
  • 已经完成了一个前后端分离的项目
  • 登录接口的实现,并可以完成接口鉴权

在上一篇中说到大部分的接口需要登录才能访问的时候,最好的方式就是默认所有的接口都需要登录才能访问才是合理的。

另一方面,有些接口需要对用户的权限级别进行判断,例如 superadmin/admin/user。

这些都实现以后,就需要站在前端的角度上如何使用token的问题了。

配置流程

配置 @Public() 修饰符

这个配置的本质是增加一个守卫,基于 AuthGuard('jwt') 做二次处理,使所有的接口默认都需要登录才能访问,而不需要登录的接口,则增加 @Public() 来声明。

增加文件:

// src/common/public.guard.ts

/**
 * 登录权限装饰器
 * 基于  AuthGuard('jwt') 进行了二次处理
 * 正常用法是在控制器中对需要登录才能使用的接口用 @UseGuards(AuthGuard('jwt')) 进行装饰
 * 因为大多数接口都需要鉴权,所以进行了一层包装,只有不需要登录的接口才进行鉴权
 * 使用方法 控制器中  @Public() 进行装饰
 */

import { SetMetadata, ExecutionContext, Injectable } from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { AuthGuard } from '@nestjs/passport'
import { Observable } from 'rxjs'

@Injectable()
export class JwtPublicGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super()
  }

  canActivate(
    context: ExecutionContext
  ): boolean | Promise<boolean> | Observable<boolean> {
    const isPublic = this.reflector.getAllAndOverride('isPublic', [
      context.getHandler(),
      context.getClass()
    ])

    if (isPublic) return true

    return super.canActivate(context)
  }
}

export const Public = () => SetMetadata('isPublic', true)

然后在 AppModule 中进行挂载:

// app.module.ts

import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { TypeOrmModule } from '@nestjs/typeorm'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { JwtStrategy } from './common/jwt.strategy'
import { Admin } from './modules/admin/admin.entity'
import { AdminModule } from './modules/admin/admin.module'
import { CompanyModule } from './modules/company/company.module'
import { ScaleModule } from './modules/scale/scale.module'
import { QuestionModule } from './modules/question/question.module'
import { DemensionModule } from './modules/demension/demension.module'
import { UserModule } from './modules/user/user.module'
import { PublicModule } from './modules/public/public.module'
import { jwtKey } from './config'
import { APP_GUARD } from '@nestjs/core'
import { JwtPublicGuard } from './common/public.guard'
import { RolesGuard } from './common/role.guard'
import { ReportModule } from './modules/report/report.module'

//--importModule

@Module({
  imports: [
    // 使用 typeorm 链接数据库
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: '39.97.243.166',
      port: 3306,
      username: 'root',
      password: 'ziyi0525',
      timezone: 'local', // 时区
      database: 'nest',
      entities: ['dist/modules/**/*.entity{.ts,.js}'],
      synchronize: true,
      dateStrings: true
    }),
    // 配置jwt
    JwtModule.register({
      ...jwtKey,
      signOptions: { expiresIn: '24h' }
    }),
    // 引用数据
    TypeOrmModule.forFeature([Admin]),
    // 加载子模块
    AdminModule,
    CompanyModule,
    ScaleModule,
    QuestionModule,
    DemensionModule,
    UserModule,
    PublicModule,
    ReportModule
    //--addModule
  ],
  controllers: [AppController],
  providers: [
    AppService,
    JwtStrategy,
    // 挂载 - 登录权限控制装饰器
    {
      provide: APP_GUARD,
      useClass: JwtPublicGuard
    },
  ]
})
export class AppModule {}

注意挂载顺序,JwtPublicGuard 应该在 JwtStrategy 之后。

至此我们再来测试一下 getList 接口:

首先要把上文中添加的 @UseGuards(AuthGuard('jwt')) 注释掉:

image.png

然后修改 rest 请求,为了避免 token 未失效,所以选择直接把 token 也干掉:

image.png

到现在为止,请求 /user/list 应该是会直接返回数据的,因为按照之前的配置,是不需要登录就可以访问的,然而:

image.png

只有加上登录信息:

image.png

才能正常访问。

这样一来,login接口则也是需要登录了:

image.png

我们需要修改login:

image.png

然后再访问:

image.png

至此,@Public() 修饰符的用法清晰了。

添加 @Role('admin') 修饰符

有些模块需要更高的权限,例如:权限管理、流程审批、网站基本信息的修改。这就需要使用户拥有一个新的维度:角色(Role)。

首先编写代码如下:

// src/common/role.guard.ts

import {
  SetMetadata,
  CanActivate,
  ExecutionContext,
  Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { matchRole } from './role.match';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
  
    const isPublic = this.reflector.getAllAndOverride('isPublic', [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }
  
    const role = this.reflector.get<string>('role', context.getHandler());

    const req = context.switchToHttp().getRequest();
    const user = req.user;

    // 根据接口规定进行判断是否满足返回 boolean 。。。
    return matchRole(role, user.role);
  }
}

export const Role = (role: string) => SetMetadata('role', role);


// src/common/role.match.ts

/**
 * 角色鉴权
 * @param role 接口要求权限
 * @param userRole 用户权限
 * @returns boolean
 */
export const matchRole: (role: string, userRole: number) => boolean = (
  role,
  userRole,
) => {
  console.log(role, userRole);

  // 如果没有使用装饰器,则返回 true
  if (!role) {
    return true;
  }

  // 如果是超级管理员或者管理员,则返回 true
  if (userRole === 0 || userRole === 1) {
    return true;
  }

   return false;
};

如上所示 role.guard.ts 的结构和 puglic/guard.ts 很相似,这就是声明了一个装饰器。

然后再在 AppModule 上挂载,挂载在 publicGuard 后面:

// app.module.ts

// ...
providers: [
    AppService,
    JwtStrategy,
    // 挂载 - 登录权限控制装饰器
    {
      provide: APP_GUARD,
      useClass: JwtPublicGuard,
    },
    // 挂载 - 角色权限控制装饰器
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
// ...

然后就可以在使用的地方使用了,还是拿 getList 接口来访问:

先改造一下接口:

// user.controller.ts

  // @UseGuards(AuthGuard('jwt'))
  @Role('admin')
  @Get('list')
  getList(@Req() req: Request | any) {
    console.log('当前操作用户:', req.user);
    return this.userService.getList();
  }

如上所示,这个接口现在应该是需要登录,且用户角色是 admin 才能访问,为了查看效果,在 role.guard.ts 中添加打印信息:

image.png

然后访问:

image.png

生效了,因为当前用户根本就没有 权限(role) 字段,所以当然就返回了 403。而且通过打印信息看到传入的 role 值也在。

如果想提前体验,则修改 matchRole 的返回值,改为 true 即可。

image.png

image.png

前后端分离实现注册登录

目前理论阶段已经全部测试通过了,如何配合前端实现呢?

回到前文中创建的 vue 项目,做如下设计:

  1. 增加 axios 守卫,封装成 Http,如果接口返回 401 则重定向页面到 login,如果接口返回 403 则提示权限不足。
  2. 增加 login 页面,用来实现登录。
  3. 在 home 页面中,添加用户的时候增加字段 role
  4. 在 数据结构中增加字段 role,并且配置 jwt 的 payload ,确保在 RoleGuard 中拿到 role 信息。

axios 进行简单封装,网上已经有很多例子了,而我自己开个新的文章分享我自己的,只粘一下代码:

// Http.ts

import { useMainStore } from '../store'
import pinia from '../store/install' // 这个实例需要和App挂载的是同一个
import router from '../router'

import axios from 'axios'
import Message from './message'

axios.defaults.baseURL = '/'
axios.defaults.timeout = 10 * 1000

// const globalLoading = false;
// const ajaxNum = 0;
const mainStore = useMainStore(pinia)

// http request 拦截器
axios.interceptors.request.use(
  (req) => {
    req.headers = {
      'Content-Type': 'application/json;charset=UTF-8',
      Authorization: 'Bearer ' + mainStore.token
    }
    // console.log('🚀 ~ file: http.ts ~ line 21 ~ req.headers', req.headers);

    return req
  },
  (err) => {
    return err
  }
)

// http response 拦截器
axios.interceptors.response.use(
  (res: any) => {
    console.log('res.code => ', res.data.code)

    if (res.status === 201 || res.status === 200) {
      return res.data.data
    } else {
      return Message.error(res.data.message)
    }
  },
  (err) => {
    const res = err.response

    if (res && res.status === 401) {
      Message.error('您还未登录或登录状态失效!')
      return router.push({ path: '/login' })
    } else if (res && res.status === 404) {
      Message.error('接口不存在或已被转移!')
    } else {
      Message.error('服务器异常,获取数据失败!')
    }
    Message.error(err.data.code)
    return false
  }
)

export interface ResType<T> {
  code: number
  data?: T
  message: string
  err?: string
}

export class Http {
  static get<T>(url: string, params?: any): Promise<T> {
    return axios.get(url, params)
  }
  static put<T>(url: string, params?: any): Promise<T> {
    return axios.put(url, params)
  }
  static post<T>(url: string, params?: any): Promise<T> {
    return axios.post(url, params)
  }
  static delete<T>(url: string, params?: any): Promise<T> {
    return axios.delete(url, params)
  }
  static download(url: string) {
    const iframe = document.createElement('iframe')
    iframe.style.display = 'none'
    iframe.src = url
    iframe.onload = function () {
      document.body.removeChild(iframe)
    }
    document.body.appendChild(iframe)
  }
}

// utils/message.ts

import { ElMessage, ElMessageBox } from 'element-plus'

const base = (msg: string) => ({
  duration: 4000,
  showClose: true,
  message: msg,
  dangerouslyUseHTMLString: true
})

export default class Message {
  static error(msg: string) {
    return ElMessage({
      type: 'error',
      ...base(msg)
    })
  }

  static success(msg: string) {
    return ElMessage({
      type: 'success',
      ...base(msg)
    })
  }

  static warning(msg: string) {
    return ElMessage({
      type: 'warning',
      ...base(msg)
    })
  }

  static info(msg: string) {
    return ElMessage({
      type: 'info',
      ...base(msg)
    })
  }

  static confirm(msg: string, resovleFn: () => void) {
    return ElMessageBox.confirm(msg, '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
      .then(() => {
        resovleFn()
      })
      .catch(() => Message.info('哦,原来是点错啦!'))
  }

  static delete(msg: string, resovleFn: () => void) {
    return ElMessageBox.confirm(msg, '删除', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
      .then(() => {
        resovleFn()
      })
      .catch(() => Message.info('哦,原来是点错啦!'))
  }
}

// store/index.ts

import { defineStore } from 'pinia'
import pinia from './install'

const useMainStore = defineStore('main', {
  state: () => ({
    loading: false,
    token: '',
    currentScale: 0,
    userInfo: {
      username: '', // 用户名
      realname: '', // 真实姓名
      sex: '', // 性别
      phone: '', // 电话
      email: '', // 邮箱
      birth: '', // 出生日期
      avantor: '' // 头像
    }
  }),
  getters: {
    getUsername: (state:any) => state.userInfo?.username,
    getToken: (state:any) => state.token,
    getCurrentScale: (state:any) => state.currentScale
  }
  // persist: {
  //   enabled: true,
  //   strategies: [
  //     {
  //       storage: localStorage,
  //     },
  //   ],
  // },
})

// 数据持久化
// 1. 保存数据
const instance = useMainStore(pinia)
instance.$subscribe((_:any, state:any) => {
  localStorage.setItem(
    'login-store',
    JSON.stringify({
      ...state
    })
  )
})
// 2. 获取保存的数据,先判断有无,无则用先前的
const old = localStorage.getItem('login-store')
if (old) {
  instance.$state = JSON.parse(old)
}

export { useMainStore }


// store/install.ts

// 引入 pinia
import { createPinia } from 'pinia'

// 持久化工具
// import piniaPluginPersist from 'pinia-plugin-persist';

// 实例化
const store = createPinia()

// 使用持久化工具
// 还需要再对应的 store 中开启 persist 字段
// store.use(piniaPluginPersist);

// 导出 store
export default store

// #  注意:在其他模块中使用store的话,
// #  必须引入这个实例,
// #  不然无论如何都会失去响应式,
// #  而且和挂载在App上的store不是同一个

将 store 挂载在 main.ts 上:

import { createApp } from 'vue'

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'

import router from './router'
import App from './App.vue'
import pinia from  './store/install'

const app = createApp(App)

app.use(ElementPlus)
app.use(router)
app.use(pinia)

app.mount('#app')

image.png

因为是个demo项目,所以 Http 的 baseUrl 改成了 /,而实际工作中一般会是 /api/[appName]。然后再来实现登录。

页面设计如下:

image.png

<template>
  <div class="login-page">
    <h1>登录</h1>
    <el-form
      ref="ruleFormRef"
      :model="state.ruleForm"
      :rules="state.rules"
      label-width="120px"
      class="demo-state.ruleForm"
      status-icon
    >
      <el-form-item label="用户名" prop="username">
        <el-input v-model="state.ruleForm.username" />
      </el-form-item>
      <el-form-item label="密码" prop="password">
        <el-input v-model="state.ruleForm.password" />
      </el-form-item>
    </el-form>

    <p>
      <el-button @click="handleLogin">登录</el-button>
    </p>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import type { FormInstance } from 'element-plus'
import router from '../router'
import { useMainStore } from '../store'

const mainStore = useMainStore()

const ruleFormRef = ref()
const state = reactive({
  ruleForm: {
    username: '',
    password: '',
  },
  rules: {
    username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
  },
})

const handleLogin = async () => {
  if (!ruleFormRef) return
  await ruleFormRef.value.validate().then(async (v: boolean) => {
    if (v) {
      const { token, info } = await axios.post('/api/login', state.ruleForm)

      mainStore.$patch({
        token: token,
        userInfo: info,
      })

      router.push({ path: '/' })
    } else {
      console.log('validate error!!!')
    }
  })
}
</script>

<style lang="scss" scoped>
.login-page {
  width: 560px;
  margin: 0 auto;
  text-align: center;

  .home-tools {
    border-bottom: 1px solid #eee;
    padding-bottom: 16px;
    margin: 16px 0;
  }
}
</style>

如代码所示,登录成功以后会更新 token 到 store 中,每次请求接口的时候会在 header 中拼接 token,这样让接口识别。

实际效果如下:

image.png

image.png

可以看到接口能正常访问啦!token 也正常携带上了。

然后进一步把添加用户的接口和删除接口都进行处理,就可以实现 只有管理员才能进行新增和删除的操作了。

具体代码就不粘了。

下一步计划

至此的话,其实这个服务已经算是满足了大部分的功能了,但是可以在控制台看到,每次请求只有报错会有提示,其实是没有日志的。

添加日志之前有必要回溯一下到目前为止用到的一些关于 nest 的 api 和修饰符,例如:@Post@Get@UseGuard等等。这就需要站在 Nest 文档的角度,简单了解一下 Nest 的声明周期以及自有的 Api了。

所以下一步将回溯一下到目前为止我们用到了哪些东西?这些东西是干嘛的?除了用到的,还有哪些是 Nest 已经提供了的。

参考文档

这篇的参考文档已经找不到了,里面用到的代码都是从已经完成的项目里面粘过来,一些地方调整了一下,都不难。

如果在CV过程中遇到问题,可以在评论区跟我说一下,尽量会在第一时间回复~~~