快速创建Contoller、Service、Module以及DTO文件的方式:
nest g resource 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权限的账号可以修改用户角色。
接下来实现注册用户的业务逻辑
register 注册用户
实现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);
}
}
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标记类。
其实这两种方式结合使用也完全可以的。
用户登录
用户登录这块,前面也提到了打算使用两种方式,一种是本地身份验证(用户名&密码),另一种是使用微信扫码登录。先来看一下本地身份验证登录如何实现。
passport.js
首先介绍有个专门做身份认证的Nodejs中间件:Passport.js,它功能单一,只能做登录验证,但非常强大,支持本地账号验证和第三方账号登录验证(OAuth和OpenID等),支持大多数Web网站和服务。
passport中最重要的概念是策略,passport模块本身不能做认证,所有的认证方法都以策略模式封装为插件,需要某种认证时将其添加到package.json即可, 这里我不会详细去讲passport实现原理这些, 如果感兴趣可以留言,我单独准备一篇文章来分享登录认证相关的一些内容(Nodejs不止可以用passport,还有其他不错的包)。
local 本地认证
首先安装一下依赖包,前面说了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类, 接受两个参数 -
- 第一个参数: Strategy,你要用的策略,这里是passport-local
- 第二个参数:是策略别名,上面是
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方式。
jwt 生成token
接着我们要实现的就是,验证成功后,生成一个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是否正确,比如获取用户信息接口。
获取用户信息接口实现
实现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,常见的方式有以下几种:
- fromHeader:在Http 请求头中查找
JWT - fromBodyField: 在请求的
Body字段中查找JWT - fromAuthHeaderAsBearerToken:在授权标头带有
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);
...