大家好我是考拉🐨,这是 Nest.js 实战系列第二篇,我要用最真实的场景让你学会使用 Node 主流框架。
上一篇中 【Nest.js入门之基本项目搭建】 带大家入门了Nest.js
, 接下来在之前的代码上继续进行开发, 主要两个任务:实现用户的注册与登录。
在实现登录注册之前,需要先整理一下需求, 我们希望用户有两种方式可以登录进入网站来写文章, 一种是账号密码登录,另一种是微信扫码登录。文章内容大纲
接着上章内容开始...
前面我们创建文件都是一个个创建的, 其实还有一个快速创建Contoller
、Service
、Module
以及DTO
文件的方式:
nest g resouce user
这样我们就快速的创建了一个REST API
的模块,里面简单的CRUD
代码都已经实现了,哈哈,发现我们前面一章学习的一半的内容,可以一句命令就搞定~
在注册功能中,当用户是通过用户名和密码进行注册,密码我们不能直接存明文在数据库中,所以采用bcryptjs
实现加密, 然后再存入数据库。
实现注册之前,先了解一下加密方案bcryptjs
,安装一下依赖包:
npm install bcryptjs
bcryptjs
是nodejs中比较好的一款加盐(salt
)加密的包, 我们处理密码加密、校验要使用到的两个方法:
/**
* 加密处理 - 同步方法
* bcryptjs.hashSync(data, salt)
* - data 要加密的数据
* - slat 用于哈希密码的盐。如果指定为数字,则将使用指定的轮数生成盐并将其使用。推荐 10
*/
const hashPassword = bcryptjs.hashSync(password, 10)
/**
* 校验 - 使用同步方法
* bcryptjs.compareSync(data, encrypted)
* - data 要比较的数据, 使用登录时传递过来的密码
* - encrypted 要比较的数据, 使用从数据库中查询出来的加密过的密码
*/
const isOk = bcryptjs.compareSync(password, encryptPassword)
接下来设计用户实体:
// use/entities/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('user')
export class User {
@PrimaryGeneratedColumn('uuid')
id: number;
@Column({ length: 100 })
username: string; // 用户名
@Column({ length: 100 })
nickname: string; //昵称
@Column()
password: string; // 密码
@Column()
avatar: string; //头像
@Column()
email: string;
@Column('simple-enum', { enum: ['root', 'author', 'visitor'] })
role: string; // 用户角色
@Column({
name: 'create_time',
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP',
})
createTime: Date;
@Column({
name: 'update_time',
type: 'timestamp',
default: () => 'CURRENT_TIMESTAMP',
})
updateTime: Date;
@BeforeInsert()
async encryptPwd() {
this.password = await bcrypt.hashSync(this.password);
}
}
User
实体, 使用@PrimaryGeneratedColumn('uuid')
创建一个主列id
,该值将使用uuid
自动生成。Uuid
是一个独特的字符串;createTime
和updateTime
字段转为下划线命名方式存入数据库, 只需要在@Column
装饰器中指定name
属性;@BeforeInsert
来装饰encryptPwd
方法,表示该方法在数据插入之前调用,这样就能保证插入数据库的密码都是加密后的。root
、autor
和 visitor
, root
有所以权限,author
有写文章权限,visitor
只能阅读文章, 注册的用户默认是visitor
,root
权限的账号可以修改用户角色。接下来实现注册用户的业务逻辑
实现user.service.ts
逻辑:
import { User } from './entities/user.entity';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { Repository } from 'typeorm';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
async register(createUser: CreateUserDto) {
const { username } = createUser;
const existUser = await this.userRepository.findOne({
where: { username },
});
if(existUser){
throw new HttpException("用户名已存在", HttpStatus.BAD_REQUEST)
}
const newUser = await this.userRepository.create(createUser)
return await this.userRepository.save(newUser);
}
}
犹记当时,写向数据库插入数据时,没仔细看文档,直接调用了create
,结果发现数据并没有插入数据库, 后来发现save
方法才是执行插入数据。
this.userRepository.create(createUser)
// 相当于
new User(createUser) // 只是创建了一个新的用户对象
到这里就实现了注册用户的业务逻辑, Controller
比较简单, 后面登录等业务实现,不再一一呈现Controller
代码:
// user.controller.ts
@ApiOperation({ summary: '注册用户' })
@ApiResponse({ status: 201, type: [User] })
@Post('register')
register(@Body() createUser: CreateUserDto) {
return this.userService.register(createUser);
}
执行上面代码, 返回的数据内容如下:
{
"data": {
"username": "admin",
"password": "$2a$10$vrgqi356K00XY6Q9wrSYyuBpOIVf2E.Vu6Eu.HQcUJP.hDTuclSEW",
"nickname": null,
"avatar": null,
"email": null,
"id": "5c240dcc-a9b1-4262-8212-d5ceb2815ef8",
"createTime": "2021-11-16T03:00:16.000Z",
"updateTime": "2021-11-16T03:00:16.000Z"
},
"code": 0,
"msg": "请求成功"
}
可以发现密码也被返回了,这个接口的风险不言而喻,如何处理呢?可以思考一下~
从两方面考虑, 一个是数据层面,从数据库就不返回password
字段,另一种方式是在返回数据给用户时,处理数据,不返回给前端。我们分别看一下这两种方式:
方法1
TypeORM提供的列属性select
,进行查询时是否默认隐藏此列。但是这只能用于查询时, 比如save
方法的返回的数据就仍然会包含password
。
// user.entity.ts
@Column({ select: false}) // 表示隐藏此列
password: string; // 密码
使用这种方式,我们user.service.ts
中的代码可以做如下修改:
// user.service.ts
async register(createUser: CreateUserDto) {
...
await this.userRepository.save(newUser);
return await this.userRepository.findOne({where:{username}})
}
方法2
使用class-transformer
提供的Exclude
来序列化,对返回的数据实现过滤掉password
字段的效果。首先在user.entity.ts
中使用@Exclude
装饰:
// user.entity.ts
...
import { Exclude } from 'class-transformer';
@Exclude()
@Column()
password: string; // 密码
接着在对应请求的地方标记使用ClassSerializerInterceptor
,此时,POST /api/user/register
这个请求返回的数据中,就不会包含password
这个字段。
@UseInterceptors(ClassSerializerInterceptor)
@Post('register')
register(@Body() createUser: CreateUserDto) {...}
此时可以不用像方法1那样,修改user.service.ts
中的逻辑。如果你想让该Controller
中所有的请求都不包含password
字段, 那可以直接用ClassSerializerInterceptor
标记类。
其实这两种方式结合使用也完全可以的。
用户登录这块,前面也提到了打算使用两种方式,一种是本地身份验证(用户名&密码),另一种是使用微信扫码登录。先来看一下本地身份验证登录如何实现。
首先介绍有个专门做身份认证的Nodejs中间件:Passport.js
,它功能单一,只能做登录验证,但非常强大,支持本地账号验证和第三方账号登录验证(OAuth和OpenID等),支持大多数Web网站和服务。
passport
中最重要的概念是策略,passport
模块本身不能做认证,所有的认证方法都以策略模式封装为插件,需要某种认证时将其添加到package.json
即可, 这里我不会详细去讲passport
实现原理这些, 如果感兴趣可以留言,我单独准备一篇文章来分享登录认证相关的一些内容(Nodejs不止可以用passport
,还有其他不错的包)。
首先安装一下依赖包,前面说了passport
本身不做认证, 所以我们至少要安装一个passport
策略, 这里先实现本地身份验证,所以先安装passport-local
:
npm install @nestjs/passport passport passport-local
npm install @types/passport @types/passport-local
我们还安装了一个类型提示,因为passport
是纯js的包,不装也不会影响程序运行,只是写的过程中没有代码提示。
创建一个auth
模块,用于处理认证相关的代码,Controller
、service
等这些文件夹创建方式就不重复了。我们还需要创建一个local.strategy.ts
文件来写本地验证策略代码:
// local.strategy.ts
...
import { compareSync } from 'bcryptjs';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-local';
import { User } from 'src/user/entities/user.entity';
export class LocalStorage extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {
super({
usernameField: 'username',
passwordField: 'password',
} as IStrategyOptions);
}
async validate(username: string, password: string) {
const user = await this.userRepository
.createQueryBuilder('user')
.addSelect('user.password')
.where('user.username=:username', { username })
.getOne();
if (!user) {
throw new BadRequestException('用户名不正确!');
}
if (!compareSync(password, user.password)) {
throw new BadRequestException('密码错误!');
}
return user;
}
}
我们从上至下的分析一下代码实现:
LocalStorage
继承至@nestjs/passport
提供的PassportStrategy
类, 接受两个参数passport-local
,默认就是local
super
传递策略参数, 这里如果传入的就是username
和password
,可以不用写,使用默认的参数就是,比如我们是用邮箱进行验证,传入的参数是email
, 那usernameField
对应的value就是email
。validate
是LocalStrategy
的内置方法, 主要实现了用户查询以及密码对比,因为存的密码是加密后的,没办法直接对比用户名密码,只能先根据用户名查出用户,再比对密码。addSelect
添加password
查询, 否则无法做密码对比。有了这个策略,我们现在就可以实现一个简单的 /auth/login
路由,并应用Nest.js
内置的守卫AuthGuard
来进行验证。打开 app.controller.ts
文件,并将其内容替换为以下内容:
...
import { AuthGuard } from '@nestjs/passport';
@ApiTags('验证')
@Controller('auth')
export class AuthController {
@UseGuards(AuthGuard('local'))
@UseInterceptors(ClassSerializerInterceptor)
@Post('login')
async login(@Body() user: LoginDto, @Req() req) {
return req.user;
}
}
同时不要忘记在auth.module.ts
导入PassportModule
和实体User
,并且将LocalStorage
注入,提供给其模块内共享使用。
// auth.module.ts
...
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/user/entities/user.entity';
import { LocalStorage } from './local.strategy';
@Module({
imports: [TypeOrmModule.forFeature([User]), PassportModule],
controllers: [AuthController],
providers: [AuthService, LocalStorage],
})
接口返回的数据如下,这是我们所需要的吗?
开发中登录完,不是应该返回一个可以识别用户token
这样的吗?
是的,客户端使用用户名和密码进行身份验证,服务器验证成功后应该签发一个身份标识的东西给客户端,这样以后客户端就拿着这个标识来证明自己的身份。而标识用户身份的方式有多种,这里我们采用jwt
方式(关于身份认证可以看这篇文章 前端鉴权必须了解的5种方式:cookie、session、token、jwt与单点登录)。
接着我们要实现的就是,验证成功后,生成一个token
字符串返回去。而jwt
是一种成熟的生成token
字符串的方案,它生成的token
内容是这种形式:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQyZTZkNjRlLWU1YTAtNDhhYi05ZjU2LWMyMjY3ZjRkZGMyNyIsInVzZXJuYW1lIjoiYWRtaW4xIiwicm9sZSI6InZpc2l0b3IiLCJpYXQiOjE2Mzc1NjMzNjUsImV4cCI6MTYzNzU3Nzc2NX0.NZl4qLA2B4C9qsjMjaXmZoFUyNjt2FH4C-zGSlviiXA
这种东西怎么生成的呢?
通过上图可以看出JWT token
由三个部分组成,头部(header)、有效载荷(payload)、签名(signature)。实践一下
npm install @nestjs/jwt
首先注册一下JwtModule
, 在auth.module.ts
中实现:
...
import { JwtModule } from '@nestjs/jwt';
const jwtModule = JwtModule.register({
secret:"test123456",
signOptions: { expiresIn: '4h' },
})
@Module({
imports: [
...
jwtModule,
],
exports: [jwtModule],
})
上面代码中,是通过将secret
写死在代码中实现的,这种方案实际开发中是不推荐的,secret
这种私密的配置,应该像数据库配置那样,从环境变量中获取,不然secret
泄露了,别人一样可以生成相应的的token
,随意获取你的数据, 我们采用下面这种异步获取方式:
...
const jwtModule = JwtModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
secret: configService.get('SECRET', 'test123456'),
signOptions: { expiresIn: '4h' },
};
},
});
...
注意不要忘记在.env
文件中设置SECRET
配置信息。
最后我们在auth.service.ts
中实现业务逻辑:
//auth.service.ts
...
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
) {}
// 生成token
createToken(user: Partial<User>) {
return this.jwtService.sign(user);
}
async login(user: Partial<User>) {
const token = this.createToken({
id: user.id,
username: user.username,
role: user.role,
});
return { token };
}
}
到目前为止, 我们已经通过passport-local
结合jwt
实现了给用户返回一个token
, 接下来就是用户携带token
请求数据时,我们要验证携带的token
是否正确,比如获取用户信息接口。
如果对 jwt 内容感觉看的不过瘾,可以看下我之前写的这篇 jwt 完整讲解。 搞懂 JWT 这个知识点
实现token
认证,passport
也给我们提供了对应的passport-jwt
策略,实现起来也是非常的方便,废话不多,直接Q代码:
首先安装:
npm install passport-jwt @types/passport-jwt
其实jwt
策略主要实现分两步
token
token
拿到用户信息我们看一下实现:
//jwt.strategy.ts
...
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StrategyOptions, Strategy, ExtractJwt } from 'passport-jwt';
export class JwtStorage extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get('SECRET'),
} as StrategyOptions);
}
async validate(user: User) {
const existUser = await this.authService.getUser(user);
if (!existUser) {
throw new UnauthorizedException('token不正确');
}
return existUser;
}
}
在上面策略中的ExtractJwt
提供多种方式从请求中提取JWT
,常见的方式有以下几种:
JWT
Body
字段中查找JWT
Bearer
方案中查找JWT
我们采用的是fromAuthHeaderAsBearerToken
,后面请求操作演示中可以看到,发送的请求头中需要带上,这种方案也是现在很多后端比较青睐的:'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQyZTZkNjRlLWU1YTAtNDhhYi05ZjU2LWMyMjY3ZjRkZGMyNyIsInVzZXJuYW1lIjoiYWRtaW4xIiwicm9sZSI6InZpc2l0b3IiLCJpYXQiOjE2Mzc1NzUxMzMsImV4cCI6MTYzNzU4OTUzM30._-v8V2YG8hZWpL1Jq3puxBlETeSuWg8DBEPCL2X-h5c'
不要忘记在auth.module.ts
中注入JwtStorage
:
...
import { JwtStorage } from './jwt.strategy';
@Module({
...
providers: [AuthService, LocalStorage, JwtStorage],
...
})
最后只需要在Controller
中使用绑定jwt
授权守卫:
// user.controller.ts
@ApiOperation({ summary: '获取用户信息' })
@ApiBearerAuth() // swagger文档设置token
@UseGuards(AuthGuard('jwt'))
@Get()
getUserInfo(@Req() req) {
return req.user;
}
到这里获取用户信息接口就告一段落, 最后为了可以顺畅的使用Swagger
来测试传递bearer token
接口,需要添加一个addBearerAuth
:
// main.ts
...
const config = new DocumentBuilder()
.setTitle('管理后台')
.setDescription('管理后台接口文档')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
await app.listen(9080);
...
到这里本地验证登录就完成了,通过上面的学习,关于登录这块的流程相信大家都已经掌握了, 接下来我再分享一下开发过程中我是如何实现微信扫码登录的。
注意:这块需要有微信开放平台的账号,如果没有也可以通过公众平台测试账号系统申请,具体流程这里就不说了。
首先需要申请一个应用,拿到AppID
和AppSecret
image.png
其次需要配置授权回到域名,也就是扫码成功后跳转的网站的域名。
image.png
假如你设置的是www.baidu.com
,那么http://www.baidu.com/aaa?code=xxx
是可以成功的,但是扫码成功后你要跳转http://lms.baidu.com/aaa?code=xxx
, 那就不行,会提示:redirect_uri 参数错误。
准备好账号后,再看看我们要做的需求是什么样的。
微信扫码登录时非常常见的需求,让用户使用微信登录第三方应用或者网站,一般就两种展现方式:
这里采用的是第一种,直接重定向的方式,重定向后页面展示这样的:
用一张图来展示整个流程:
从图中可以看出微信登录需要网站页面,微信客户端,网站服务端和微信开放平台服务的参与,上面这些流程微信官方文档也有,就不详细的解释了。下面我们会以代码来实现一下, 后端分为以下四个步骤:
code
换取微信接口调用凭证access_token
access_token
获取用户信息token
给前端首先实现重定向到微信扫码登录页面,这部分可以前端来完成,也可以后端来进行重定向。如果后端来做重定向也是比较简单, 只需要使用AppId
和redirectUri
回调地址就能拼接出来,代码如下:
// auth.controller.ts
@ApiOperation({ summary: '微信登录跳转' })
@Get('wechatLogin')
async wechatLogin(@Headers() header, @Res() res) {
const APPID = process.env.APPID;
const redirectUri = urlencode('http://lms.siyuanren.com/web/login_front.html');
res.redirect(
`https://open.weixin.qq.com/connect/qrconnect?appid=${APPID}&redirect_uri=${header.refere}&response_type=code&scope=snsapi_login&state=STATE#wechat_redirect`,
);
}
通过微信客户端扫码登录后,会重定向redirect_uri
传递的地址,并且带上code
参数的,此时前端将code
传给后端, 后端就可以完成接下来的2,3,4
步骤了。
在auth.controller.ts
中继续写微信登录接口:
//auth.controller.ts
@ApiOperation({ summary: '微信登录' })
@ApiBody({ type: WechatLoginDto, required: true })
@Post('wechat')
async loginWithWechat(@Body('code') code: string) {
return this.authService.loginWithWechat(code);
}
接着在auth.service.ts
中实现获取access_token
具体的逻辑:
// auth.service.ts
...
import {AccessTokenInfo, AccessConfig, WechatError, WechatUserInfo} from './auth.interface';
import { lastValueFrom } from 'rxjs';
import { AxiosResponse } from 'axios';
constructor(
...
private userService: UserService,
private httpService: HttpService,
) {}
// 获取access_token
async getAccessToken(code) {
const { APPID, APPSECRET } = process.env;
if (!APPSECRET) {
throw new BadRequestException('[getAccessToken]必须有appSecret');
}
if (
!this.accessTokenInfo ||
(this.accessTokenInfo && this.isExpires(this.accessTokenInfo))
) {
// 使用httpService请求accessToken数据
const res: AxiosResponse<WechatError & AccessConfig, any> =
await lastValueFrom(
this.httpService.get(
`${this.apiServer}/sns/oauth2/access_token?appid=${APPID}&secret=${APPSECRET}&code=${code}&grant_type=authorization_code`,
),
);
if (res.data.errcode) {
throw new BadRequestException(
`[getAccessToken] errcode:${res.data.errcode}, errmsg:${res.data.errmsg}`,
);
}
this.accessTokenInfo = {
accessToken: res.data.access_token,
expiresIn: res.data.expires_in,
getTime: Date.now(),
openid: res.data.openid,
};
}
return this.accessTokenInfo.accessToken;
}
获取到access_token
, 其实这个接口中除了access_token
还有几个参数,我们也是需要使用到的,这里简单说明一下:
参数 | 版本 |
---|---|
access_token | 接口调用凭证 |
expires_in | access_token 接口调用凭证超时时间,单位(秒) |
refresh_token | 用户刷新 access_token |
openid | 授权用户唯一标识 |
scope | 用户授权的作用域,使用逗号(,)分隔 |
openid
就是我们对于微信注册的用户的唯一标识, 那么此时就可以去数据库中查找用户是否存在,如果不存在就注册一个新用户:
// auth.service.ts
async loginWithWechat(code) {
if (!code) {
throw new BadRequestException('请输入微信授权码');
}
await this.getAccessToken(code);
// 查找用户是否存在
const user = await this.getUserByOpenid();
if (!user) {
// 获取微信用户信息,注册新用户
const userInfo: WechatUserInfo = await this.getUserInfo();
return this.userService.registerByWechat(userInfo);
}
return this.login(user);
}
async getUserByOpenid() {
return await this.userService.findByOpenid(this.accessTokenInfo.openid);
}
这里实现的代码比较长,就不全部展示,请求微信开放平台接口都类似,就省略了使用access_token
获取用户信息,需要源码可以自行获取。
如果你有兴趣,可以将微信登录这块封装成一个模块,这样微信公众平台的请求就不用都混杂在auth
模块中。
最后给大家演示一下成果:
微信扫码登录实现起来还是比较简单的,登录注册这块文章介绍的比较详细,内容比较长,就单独一章吧,将完善文章模块以及上传文件功能放在下一篇文章中,希望对大家的学习能提供一点帮助。
项目实战 git 地址:https://github.com/koala-coding/
文章实现了实现了注册、以及JWT本地认证登录和微信扫码登录,总体看起来可以, 实际上埋了两个坑。
token
没有设置过期时间,这样风险极大;access_token
是都时效性的,如何实现在有效期内多次使用,而不是每次扫码都去获取access_token
这两个问题可以结合Redis
来解决, 在后面Redis
讲解中, 会针对这两个问题给出解决方案,小伙伴们可以先思考一下,我们下一篇见🐨。
参考文章: