鉴权(authentication)是指验证用户是否拥有访问系统的权利。传统的鉴权是通过密码来验证的。这种方式的前提是,每个获得密码的用户都已经被授权。
建立用户表,密码散列
要实现鉴权认证,首先需要一张 user 表。上一次我们用 NestJS 和 Typeorm 做了最基本的 crud 操作, 这次我们用 NestJS 和 node 中最流行的身份验证库 Passport 来完成鉴权认证。为了方便,我们直接沿用上次的代码库
。
创建 user module: $ nest g mo user
然后在 user 文件夹新建 user.entity.ts, 其中我们做了密码散列:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { BeforeInsert, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import * as bcrypt from "bcryptjs";
@Entity("user")
export class UserEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 20 })
username: string;
@Column({ length: 255 })
password: string;
@BeforeInsert()
async hashPassword() {
this.password = await bcrypt.hash(this.password, 10);
}
}
|
在 user.module.ts 中注册 user 表:TypeOrmModule.forFeature([UserEntity])
上一次我们直接在 module 中写了数据库连接配置,其实更常见的做法是写一个数据库配置文件。可以用环境变量设置数据库连接,这是 typeorm 数据库连接配置的参考地址
。在文件夹建立一个 .env 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# App
JWT_SECRET = 'ThisIsASecretKey'
# Database
TYPEORM_CONNECTION = mysql
TYPEORM_HOST = localhost
TYPEORM_USERNAME = root
TYPEORM_PASSWORD = 123456
TYPEORM_DATABASE = test
TYPEORM_PORT = 3306
TYPEORM_SYNCHRONIZE = true
TYPEORM_LOGGING = true
TYPEORM_ENTITIES = dist/**/*.entity.js
|
其中写了数据库配置和自定义的 jwt 密匙,关于如何生成 jwt 格式的字符串, 可以看这篇文章
, 本文只讲如何使用它来做用户登录验证。
在 app.module.ts 的 imports 数组中修改数据库为注册:TypeOrmModule.forRoot()
,然后写入 UserModule。测试一下我们的数据库连接情况:$ npm run start
, 控制台打印了 sql 语句,说明我们的连接配置是对的。查看数据库会发现新增加了 user 表。
在 user 文件夹新建 user.dto.ts:
1
2
3
4
5
6
7
8
9
10
11
12
|
import { IsString } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class UserDto {
@ApiProperty()
@IsString()
readonly username: string;
@ApiProperty()
@IsString()
readonly password: string;
}
|
然后创建 user service:$ nest g s user
,注意在 createUser 方法中一定要先 实例化 user, 再返回创建的对象。否则 user.entity.ts 中的 @BeforeInsert() 装饰的方法不会执行,密码就不会取散列后的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { UserEntity } from "./user.entity";
import { Repository } from "typeorm";
import { UserDto } from "./user.dto";
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>
) {}
async createUser(userDto: UserDto) {
// const user = Object.assign(new UserEntity(), userDto)
const user = this.userRepository.create(userDto);
return await this.userRepository.save(user);
}
async findUsername(username: string) {
return this.userRepository.findOne({ where: { username } });
}
async findAll(): Promise<UserEntity[]> {
return await this.userRepository.find();
}
}
|
实现本地认证策略
实现本地认证策略需要先安装以下依赖:
1
2
|
yarn add @nestjs/passport passport passport-local
yarn add -D @types/passport-local
|
说明一下,这一步不是必须的。其实本地认证就是做用户名和密码的核对,我们自己去实现也不算麻烦。但是为了和 NestJS 官网教程保持一致,我们也这样做。
创建 auth module: $ nest g mo auth
,在 auth 目录下创建一个 local.strategy.ts 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { AuthService } from "./auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(username: string, password: string) {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
|
使用 @nestjs/passport ,你需要继承 PassportStrategy 类来配置 passport 策略。通过调用子类中的 super() 方法传递策略选项,通过在子类中实现 validate() 方法,可以提供 verify 回调。Passport 定义的 所有策略 都是将 validate() 方法执行的结果作为 user 属性存储在当前 HTTP Request 对象 上,你也可以自定义此属性的名称。上面文件中的 validateUser 方法需要在 auth.service.ts 自己实现,因为框架不清楚你定义的密码散列方式。
1
2
3
4
5
6
7
8
9
10
11
12
|
//auth.service.ts
...
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.userService.findUsername(username);
console.log('-----------Login-----------')
if (user && bcrypt.compareSync(pass, user.password)) {
return user;
}
return null;
}
|
实现注册登录功能
创建 auth controller: $ nest g co auth
,路由功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
import { UserDto } from "./../user/user.dto";
import {
Body,
Controller,
Get,
Post,
UseGuards,
Res,
Request,
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { ApiTags, ApiBearerAuth } from "@nestjs/swagger";
import { AuthService } from "src/auth/auth.service";
@ApiBearerAuth()
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(AuthGuard("jwt"))
@Get("users")
async findAll(@Request() req): Promise<any[]> {
console.log("--------------Auth--Success---------------");
console.log(req.user);
return await this.authService.findAll();
}
@Post("signUp")
async register(@Body() req: UserDto, @Res() res) {
const result = await this.authService.register(req);
res.status(result.statusCode).send(result);
}
@UseGuards(AuthGuard("local"))
@Post("signIn")
async login(@Body() @Request() req: UserDto, @Res() res) {
console.log("----------Login--Success-----------");
console.log(req);
const result = await this.authService.login(req);
res.status(result.statusCode).send(result);
}
}
|
注意其中的 @UseGuards(AuthGuard(‘local’)) 装饰器,因为我们写了 local.strategy.ts 文件,其中继承了 PassportStrategy 类,并实现了 validate 方法。@nestjs/passport 就会为我们实现一个 AuthGuard,我们直接在需要验证的路由前使用就好。@UseGuards(AuthGuard(‘jwt’)) 是我们接下来要讲的 JWT 认证策略。
再补充完整 auth.service.ts 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
// auth.service.ts
import { BadRequestException, Injectable, Body, Request } from "@nestjs/common";
import { UserService } from "../user/user.service";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcryptjs";
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService
) {}
async findAll(): Promise<any[]> {
return await this.userService.findAll();
}
async validateUser(username: string, pass: string): Promise<any> {
const user = await this.userService.findUsername(username);
console.log("-----------Login-----------");
if (user && bcrypt.compareSync(pass, user.password)) {
return user;
}
return null;
}
async register(user: any) {
let userData: any;
userData = await this.userService.findUsername(user.username);
if (userData) {
return { statusCode: 400, message: "This username aleady exists" };
}
await this.userService.createUser(user);
userData = await this.userService.findUsername(user.username);
return {
username: userData.username,
statusCode: 201,
};
}
async login(user: any) {
return this.userService.findUsername(user.username).then((userData) => {
const Token = this.createToken(userData);
return {
username: userData.username,
access_token: Token,
statusCode: 200,
};
});
}
createToken(user: any) {
const payload = { username: user.username, sub: user.id };
return this.jwtService.sign(payload);
}
}
|
实现 JWT 认证策略
实现了用户注册登录后,我们需要保护 API,限制有的路由地址需要用户登录后才能访问,有的路由地址需要管理员登录后才能访问。我们这里只实现需要普通用户登录后才能访问的路由。
什么是 Token?
- 前后端分离模式下,Token 是我们验证用户登录的常用方式。Token 是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器会生成一个 Token 并将此 Token,返回给客户端,以后客户端只需带上这个 Token 前来请求数据即可,无需再次带上用户名和密码。
为什么要使用 Token?
- 在很多项目案例中,需要实现账户的功能,客户端所有的功能都基于用户已登陆的前提下才可以使用。这就要求每次客户端向服务器请求数据时都要验证账户是否正确,如果正确则按正常方式返回数据,如果错误则进行拦截并返回错误信息。但是当客户端频繁向服务器请求数据的话,每次服务器都要频繁地查询数据库。而 Token 正是为了 减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。并取代传统使用 session 的方法来进行验证。
在 Nest.js 中使用 jwt(json web token), 我们需要安装以下依赖:
1
2
|
yarn add @nestjs/jwt passport-jwt
yarn add -D @types/passport-jwt
|
我们在 auth.service.ts 中已经实现了生成 jwt 字符串的方法,在用户登录路由中就会调用,并返回 jwt 字符串:
1
2
3
4
|
createToken(user: any) {
const payload = { username: user.username, sub: user.id };
return this.jwtService.sign(payload);
}
|
注意
上面 sign 的参数 payload 是可逆加密的,拿到 token 后是可以解密成明文内容的,所以这部分不要放敏感信息。
我们已经创建了 jwt 字符串作为请求令牌,那么服务端如何根据 jwt 字符串的内容,找到用户信息?
我们就需要实现 jwt 认证策略,在 auth 文件夹下新建 jwt.strategy.ts 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { JWT_SECRET } from "config";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: JWT_SECRET,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
|
解释一下,对于 JWT 策略,Passport 首先验证 JWT 的签名并解码为 JSON 格式内容。仅在 @nestjs/passport 模块验证令牌有效后,才调用 validate() 方法。该方法将解码后的 JSON 作为其单个参数继续传递。否则。将阻止请求,抛出 401 Unauthorized 的异常。
现在来看我们的 auth.controller.ts,可以将 validate() 返回值输出到控制台:
1
2
3
4
5
6
7
8
9
|
// auth.controller.ts
@UseGuards(AuthGuard('jwt'))
@Get('users')
async findAll(@Request() req): Promise<any[]> {
console.log('--------------Auth--Success---------------')
console.log(req.user);
return await this.authService.findAll();
}
|
最后这是我们的 auth.module.ts,其中注册了 jwt 字符串过期时间,我们在 auth.service.ts 中注入了 UserService,记得导入 UserModule。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// auth.module.ts
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: JWT_SECRET,
signOptions: { expiresIn: "3600s" },
}),
UserModule,
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
|
启动项目:$ npm run start:dev
,打开 http://localhost:3000/docs 在 swagger 文档模型中测试我们的 api。先 signUp, 然后 signIn, 登录成功返回 access_token,点击那个锁符号,将 access_token 的值粘贴过去,就能通过认证了。
附:源码地址
参阅资料