开启掘金成长之旅!这是我参与「掘金日新计划 · 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')) 注释掉:
然后修改 rest 请求,为了避免 token 未失效,所以选择直接把 token 也干掉:
到现在为止,请求 /user/list 应该是会直接返回数据的,因为按照之前的配置,是不需要登录就可以访问的,然而:
只有加上登录信息:
才能正常访问。
这样一来,login接口则也是需要登录了:
我们需要修改login:
然后再访问:
至此,@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 中添加打印信息:
然后访问:
生效了,因为当前用户根本就没有 权限(role) 字段,所以当然就返回了 403。而且通过打印信息看到传入的 role 值也在。
如果想提前体验,则修改 matchRole 的返回值,改为 true 即可。
前后端分离实现注册登录
目前理论阶段已经全部测试通过了,如何配合前端实现呢?
回到前文中创建的 vue 项目,做如下设计:
- 增加 axios 守卫,封装成 Http,如果接口返回 401 则重定向页面到 login,如果接口返回 403 则提示权限不足。
- 增加 login 页面,用来实现登录。
- 在 home 页面中,添加用户的时候增加字段 role
- 在 数据结构中增加字段 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')
因为是个demo项目,所以 Http 的 baseUrl 改成了 /,而实际工作中一般会是 /api 或 /[appName]。然后再来实现登录。
页面设计如下:
<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,这样让接口识别。
实际效果如下:
可以看到接口能正常访问啦!token 也正常携带上了。
然后进一步把添加用户的接口和删除接口都进行处理,就可以实现 只有管理员才能进行新增和删除的操作了。
具体代码就不粘了。
下一步计划
至此的话,其实这个服务已经算是满足了大部分的功能了,但是可以在控制台看到,每次请求只有报错会有提示,其实是没有日志的。
添加日志之前有必要回溯一下到目前为止用到的一些关于 nest 的 api 和修饰符,例如:@Post、@Get、@UseGuard等等。这就需要站在 Nest 文档的角度,简单了解一下 Nest 的声明周期以及自有的 Api了。
所以下一步将回溯一下到目前为止我们用到了哪些东西?这些东西是干嘛的?除了用到的,还有哪些是 Nest 已经提供了的。
参考文档
这篇的参考文档已经找不到了,里面用到的代码都是从已经完成的项目里面粘过来,一些地方调整了一下,都不难。
如果在CV过程中遇到问题,可以在评论区跟我说一下,尽量会在第一时间回复~~~