使用 NestJS、Passport 和 Redis 设置会话
Jay 是 NestJS 核心团队的成员,主要帮助 Discord 和 Github 上的社区并为框架的各个部分做出贡献。
如果您在这里,那么您要么是我的忠实读者之一,只是在 dev.to 上偶然发现了一些有趣的内容,要么正在寻找如何使用Passport和NestJS实现会话。Nest的文档很好地展示了如何在 Passport 中使用JWT,但在如何使用会话方面却有所欠缺。也许您想使用会话存储来支持某些旧版软件。也许是因为 JWT 的范围过于复杂。也许是因为您正在寻找一种更简单的方法来设置刷新令牌。无论如何,本文都适合您。
先决条件
我将使用 NestJS(它就在标题里,希望大家看得一清二楚),并且会用到Guards,所以如果你还不了解它们是什么,强烈建议你先了解一下。别担心,我会等你。
我也不会使用Postman或Insomnia之类的 HTTP 客户端,而是使用cURL
。我喜欢尽可能多地使用终端,因为它可以在终端之间提供即时反馈。您可以随意使用任何您喜欢的客户端,但代码片段将使用 curl 命令。
说到即时反馈,我还将使用tmux
终端多路复用器,它允许我在同一个窗口和逻辑分组中同时运行多个终端。这样,我就可以保持一个终端窗口打开,查看服务器日志、docker-compose 实例和/或日志,以及执行 curl 命令,而无需使用 Alt-Tab 切换视图。非常方便,而且高度可定制。
最后,我将使用docker
和docker-compose file
运行Redis实例进行会话存储,并允许运行 redis-cli 以便能够查询 Docker 运行的 redis 实例。
所有代码都可以在这里参考和运行。需要注意的是,克隆并安装仓库后,如果要运行代码,你需要自己cd blog-posts/nestjs-passport-sessions
运行nest start --watch
。这只是我为 dev.to 博客设置仓库的一个副作用。
从头开始
如果您遵循预先构建的代码,请随意跳过此部分。
要从头开始设置类似的项目,您需要先设置一个 Nest 项目,最简单的方法是通过 Nest CLI
nest new session-authentication
选择你喜欢的包管理器,然后安装以下依赖项
pnpm i @nestjs/passport passport passport-local express-session redis connect-redis bcrypt
以及以下对等依赖项
pnpm i -D @types/passport-local @types/express-session @types/connect-redis @types/bcrypt @types/redis
npm 和 yarn 也很好用,我只是喜欢 pnpm 作为包管理器
现在您应该可以按照其余代码进行操作,边走边构建。
NestJS 和 Passport
AuthGuard()
与大多数@nestjs/
包一样,该@nestjs/passport
包主要是护照的薄包装,但 Nest 确实在护照包中做了一些很酷的功能,我认为值得一提。首先是AuthGuard
mixin。乍一看,这个 mixin 可能有点吓人,但让我们逐一了解一下。
export const AuthGuard: (type?: string | string[]) => Type<IAuthGuard> = memoize(createAuthGuard);
忽略memoize
调用,这createAuthGuard
就是类创建的神奇之处。我们最终会将type
(如果适用)传递给createAuthGuard
方法,并最终将其传回@UseGuards()
。从这里开始的所有内容,除非另有说明,都将成为方法的一部分createAuthGuard
。
class MixinAuthGuard<TUser = any> implements CanActivate {
constructor(@Optional() protected readonly options?: AuthModuleOptions) {
this.options = this.options || {};
if (!type && !this.options.defaultStrategy) {
new Logger('AuthGuard').error(NO_STRATEGY_ERROR);
}
}
...
构造函数允许可选地注入AuthModuleOptions
。这就是传递给 的内容PassportModule.register()
。这只是为了让 Nest 弄清楚是defaultStrategy
使用 还是传递给 的命名对象AuthGuard
。
async canActivate(context: ExecutionContext): Promise<boolean> {
const options = {
...defaultOptions,
...this.options,
...await this.getAuthenticateOptions(context)
};
const [request, response] = [
this.getRequest(context),
this.getResponse(context)
];
const passportFn = createPassportContext(request, response);
const user = await passportFn(
type || this.options.defaultStrategy,
options,
(err, user, info, status) =>
this.handleRequest(err, user, info, context, status)
);
request[options.property || defaultOptions.property] = user;
return true;
}
这段代码读起来相当顺畅,我们自定义了获取身份验证选项(默认返回undefined
)、获取request
和response
对象(默认为context.switchToHttp().getRequest()/getResponse()
)的方法,然后createPassportContext
调用该方法,并立即使用策略名称和选项进行返回。之后,我们设置 的req.user
返回值passportFn
并返回true
,以让请求继续。接下来的代码块不是 mixin 或类的一部分MixinAuthGuard
。
const createPassportContext = (request, response) => (type, options, callback: Function) =>
new Promise<void>((resolve, reject) =>
passport.authenticate(type, options, (err, user, info, status) => {
try {
request.authInfo = info;
return resolve(callback(err, user, info, status));
} catch (err) {
reject(err);
}
})(request, response, err => (err ? reject(err) : resolve())),
);
这里可以看到一些神奇的事情发生:Nest 最终会passport.authenticate
为我们调用,这样我们就不必自己调用它了。这样做是为了将护照包装在一个 Promise 中,以便我们能够正确地管理回调,并为authenticate
函数提供它自己的处理程序。整个方法实际上是在创建一个不同的回调函数,以便我们最终可以this.handleRequest
使用护照返回的err
、user
、info
和status
进行调用。这可能需要一些时间来理解,而且并非必需,但了解一些底层代码的作用通常总是有益的。
handleRequest(err, user, info, context, status): TUser {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
这很简单,但知道这个方法在这里很有用。正如 Nest 文档中提到的,如果您需要调试请求失败的原因,这里是一个不错的选择。通常只需添加这一行console.log({ err, user, info, context, status })
就足够了,它几乎可以帮助您找出请求护照部分中出现的所有问题。
在开始实施之前,我还想谈谈另外两个类,但我保证这是值得的!
护照策略()
所以接下来我们要看的就是 mixin PassportStrategy
。这就是我们最终将策略类的validate
方法注册到护照verify
回调中的方式。这个 mixin 在一些高级 JS 技术方面做得更多,所以,让我们再一次逐一讲解。
export function PassportStrategy<T extends Type<any> = any>(
Strategy: T,
name?: string | undefined
): {
new (...args): InstanceType<T>;
} {
abstract class MixinStrategy extends Strategy {
这部分非常简单,我们只是将护照策略类和可选的策略重命名传递给混合。
constructor(...args: any[]) {
const callback = async (...params: any[]) => {
const done = params[params.length - 1];
try {
const validateResult = await this.validate(...params);
if (Array.isArray(validateResult)) {
done(null, ...validateResult);
} else {
done(null, validateResult);
}
} catch (err) {
done(err, null);
}
};
这是构造函数的前半部分。你可能一开始就注意到了super
,至少现在还没有调用。这是因为我们正在设置一个回调函数,稍后会将其传递给 Passport。所以,这里实际上是设置了一个函数,它将被调用this.validate
并获取结果。如果结果是一个数组,我们会展开该数组(Passport 将使用第一个值),否则,我们最终会使用done
结果调用回调函数。如果发生错误,按照传统的回调方式,错误信息将作为第一个值传递给done
方法。
super(...args, callback);
const passportInstance = this.getPassportInstance();
if (name) {
passportInstance.use(name, this as any);
} else {
passportInstance.use(this as any);
}
}
现在我们最终调用了super
,并在调用过程中用我们刚刚创建的新回调覆盖了原始回调verify
。这将设置整个护照策略类,我们将使用它作为策略的名称。现在剩下要做的就是通过调用passportInstance.use(this)
(或将自定义名称作为第一个参数传递)来告知护照。
如果其中任何一个内容有点太深入,别担心。如果你真的想,可以回过头来再看,但对于本文的其余部分来说,这并非必要。
护照序列化器
终于,一个真正的类了!这是我在开始讲解会话实现之前要讲解的最直接也是最后一点。这个类通常不会在 Nest 应用程序中使用——除非你使用会话,我们接下来会解释为什么。
因此,Passport 具有序列化和反序列化用户的概念。序列化用户只是获取用户信息并对其进行压缩/使其尽可能精简。在很多情况下,这只是使用ID
用户的信息。反序列化用户则相反,获取 ID 并从中提取完整的用户信息。这通常意味着调用数据库,但如果您不想担心这一点,则没有必要。现在,Nest 有一个PassportSerializer
类似这样的类:
export abstract class PassportSerializer {
abstract serializeUser(user: any, done: Function);
abstract deserializeUser(payload: any, done: Function);
constructor() {
const passportInstance = this.getPassportInstance();
passportInstance.serializeUser((user, done) => this.serializeUser(user, done));
passportInstance.deserializeUser((payload, done) => this.deserializeUser(payload, done));
}
getPassportInstance() {
return passport;
}
}
您应该只使用一个扩展 的类PassportSerializer
,它应该负责为会话存储设置用户的通用序列化和反序列化。user
传递给 的值serializeUser
通常与 的值相同req.user
,而payload
传递给 的值是作为的deserializeUser
第二个参数传递的值。这样在代码中显示时会更直观一些。done
serializeUser
休息时间
好了,以上内容一下子介绍了很多关于 NestJS 和 Passport 的信息,还有一些相当复杂的代码需要仔细阅读。如果需要的话,可以在这里休息一下。喝杯咖啡,舒展一下腿脚,去玩玩你一直想玩的手机游戏。你想做什么就做什么,或者继续往下读这篇文章。
本地运行 Redis
您可以在本地计算机上安装并运行 Redis,也可以使用docker-compose.yml
文件在容器内运行 Redis。以下是我在撰写本文时使用的 compose 文件
# docker-compose.yml
version: '3'
services:
redis:
image: redis:latest
ports:
- '6379:6379'
rcli:
image: redis:latest
links:
- redis
command: redis-cli -h redis
然后要运行 redis,我只需使用docker compose up redis -d
。当我需要运行 redis CLI 时,我过去常常docker compose run rcli
通过 docker 网络连接到 redis 实例。
设置中间件
现在介绍一下我们将要使用的中间件:为了设置会话并存储它们,我将使用express-session,并使用connect-redis作为会话和会话存储,并使用redis作为 connect-redis 的客户端。我还将通过Nest 中间件(而不是使用)app.use
来设置我们的中间件,以便在进行端到端测试时,中间件已经设置好了(这超出了本文的讨论范围)。我还使用以下代码bootstrap
将 redis 设置为自定义提供程序。
// src/redis/redis.module.ts
import { Module } from '@nestjs/common';
import * as Redis from 'redis';
import { REDIS } from './redis.constants';
@Module({
providers: [
{
provide: REDIS,
useValue: Redis.createClient({ port: 6379, host: 'localhost' }),
},
],
exports: [REDIS],
})
export class RedisModule {}
// src/redis/redis.constants.ts
export const REDIS = Symbol('AUTH:REDIS');
这允许我们注入@Inject(REDIS)
Redis 客户端。现在我们可以像这样配置中间件:
// src/app.module.ts
import { Inject, Logger, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import * as RedisStore from 'connect-redis';
import * as session from 'express-session';
import * as passport from 'passport';
import { RedisClient } from 'redis';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth';
import { REDIS, RedisModule } from './redis';
@Module({
imports: [AuthModule, RedisModule],
providers: [AppService, Logger],
controllers: [AppController],
})
export class AppModule implements NestModule {
constructor(@Inject(REDIS) private readonly redis: RedisClient) {}
configure(consumer: MiddlewareConsumer) {
consumer
.apply(
session({
store: new (RedisStore(session))({ client: this.redis, logErrors: true }),
saveUninitialized: false,
secret: 'sup3rs3cr3t',
resave: false,
cookie: {
sameSite: true,
httpOnly: false,
maxAge: 60000,
},
}),
passport.initialize(),
passport.session(),
)
.forRoutes('*');
}
}
并准备好使用护照会话。这里有两件重要的事情要注意:
passport.initialize()
必须在之前调用passport.session()
。session()
必须先调用passport.initialize()
现在解决了这个问题,让我们继续讨论我们的授权模块。
AuthModule
首先,让我们定义User
如下
// src/auth/models/user.interface.ts
export interface User {
id: number;
firstName: string;
lastName: string;
email: string;
password: string;
role: string;
}
然后有RegisterUserDto
和LoginUserDto
作为
// src/auth/models/register-user.dto.ts
export class RegisterUserDto {
firstName: string;
lastName: string;
email: string;
password: string;
confirmationPassword: string;
role = 'user';
}
和
// src/auth/models/login-user.dto.ts
export class LoginUserDto {
email: string;
password: string;
}
现在我们将设置我们LocalStrategy
的
// src/auth/local.strategy.ts
import { Injectable } 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({
usernameField: 'email',
});
}
async validate(email: string, password: string) {
return this.authService.validateUser({ email, password });
}
}
注意,这里我们传递usernameField: 'email'
的是super
。这是因为在我们的RegisterUserDto
和中,LoginUserDto
我们使用的是email
字段,而不是username
护照的默认字段。你也可以更改passwordField
,但在本文中我没有理由这么做。现在,我们将AuthService
,
// src/auth/auth.service.ts
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { compare, hash } from 'bcrypt';
import { LoginUserDto, RegisterUserDto } from './models';
import { User } from './models/user.interface';
@Injectable()
export class AuthService {
private users: User[] = [
{
id: 1,
firstName: 'Joe',
lastName: 'Foo',
email: 'joefoo@test.com',
// Passw0rd!
password: '$2b$12$s50omJrK/N3yCM6ynZYmNeen9WERDIVTncywePc75.Ul8.9PUk0LK',
role: 'admin',
},
{
id: 2,
firstName: 'Jen',
lastName: 'Bar',
email: 'jenbar@test.com',
// P4ssword!
password: '$2b$12$FHUV7sHexgNoBbP8HsD4Su/CeiWbuX/JCo8l2nlY1yCo2LcR3SjmC',
role: 'user',
},
];
async validateUser(user: LoginUserDto) {
const foundUser = this.users.find(u => u.email === user.email);
if (!user || !(await compare(user.password, foundUser.password))) {
throw new UnauthorizedException('Incorrect username or password');
}
const { password: _password, ...retUser } = foundUser;
return retUser;
}
async registerUser(user: RegisterUserDto): Promise<Omit<User, 'password'>> {
const existingUser = this.users.find(u => u.email === user.email);
if (existingUser) {
throw new BadRequestException('User remail must be unique');
}
if (user.password !== user.confirmationPassword) {
throw new BadRequestException('Password and Confirmation Password must match');
}
const { confirmationPassword: _, ...newUser } = user;
this.users.push({
...newUser,
password: await hash(user.password, 12),
id: this.users.length + 1,
});
return {
id: this.users.length,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
};
}
findById(id: number): Omit<User, 'password'> {
const { password: _, ...user } = this.users.find(u => u.id === id);
if (!user) {
throw new BadRequestException(`No user found with id ${id}`);
}
return user;
}
}
我们的控制器
// src/auth/auth.controller.ts
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { LocalGuard } from '../local.guard';
import { AuthService } from './auth.service';
import { LoginUserDto, RegisterUserDto } from './models';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
registerUser(@Body() user: RegisterUserDto) {
return this.authService.registerUser(user);
}
@UseGuards(LocalGuard)
@Post('login')
loginUser(@Req() req, @Body() user: LoginUserDto) {
return req.session;
}
}
和我们的序列化器
// src/auth/serialization.provider.ts
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { User } from './models/user.interface';
@Injectable()
export class AuthSerializer extends PassportSerializer {
constructor(private readonly authService: AuthService) {
super();
}
serializeUser(user: User, done: (err: Error, user: { id: number; role: string }) => void) {
done(null, { id: user.id, role: user.role });
}
deserializeUser(payload: { id: number; role: string }, done: (err: Error, user: Omit<User, 'password'>) => void) {
const user = this.authService.findById(payload.id);
done(null, user);
}
}
以及我们的模块
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { AuthSerializer } from './serialization.provider';
@Module({
imports: [
PassportModule.register({
session: true,
}),
],
providers: [AuthService, LocalStrategy, AuthSerializer],
controllers: [AuthController],
})
export class AuthModule {}
我们需要做的AuthSerializer
就是将它添加到providers
数组中。Nest 会实例化它,最终会调用passport.serializeUser
(passport.deserializeUser
之前告诉过你,这会很有用)。
卫兵
现在让我们开始讨论守卫,正如你会注意到的,AuthController
我们没有使用AuthGuard('local')
,而是使用LocalGuard
。这样做的原因是,我们最终需要调用super.logIn(request)
,虽然AuthGuard
有,但默认情况下不会使用。它最终会request.login(user, (err) => done(err ? err : null, null))
为我们调用,这就是用户序列化发生的方式。这就是启动会话的方式。我再重复一遍,因为它非常重要。super.logIn(request)
这就是用户获取会话的方式LocalGuard
。要使用此方法,我们可以按如下所示设置
// src/local.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalGuard extends AuthGuard('local') {
async canActivate(context: ExecutionContext): Promise<boolean> {
const result = (await super.canActivate(context)) as boolean;
await super.logIn(context.switchToHttp().getRequest());
return result;
}
}
我们还有另一个守卫,即LoggedInGuard
。这个守卫最终只会调用request.isAuthenticated()
一个方法,当使用会话时,Passport 会将其添加到请求对象中。我们可以使用它来代替用户每次请求时都向我们传递用户名和密码,因为其中会包含一个包含用户会话 ID 的 Cookie。
// src/logged-in.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class LoggedInGuard implements CanActivate {
canActivate(context: ExecutionContext) {
return context.switchToHttp().getRequest().isAuthenticated();
}
}
现在我们还有另一个警卫来检查用户是否是管理员。
// src/admin.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { LoggedInGuard } from './logged-in.guard';
@Injectable()
export class AdminGuard extends LoggedInGuard {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
return super.canActivate(context) && req.session.passport.user.role === 'admin';
}
}
这个守卫扩展了我们的常规功能,并通过我们之前创建的LoggedInGuard
功能检查用户的角色(该角色保存在 redis 会话中) 。AuthSerializer
几节额外的课
我还用到了一些其他的类。在 GitHub 仓库中查看它们最方便,但如果你想要直接复制粘贴,我会把它们添加到这里:
// src/app.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AdminGuard } from './admin.guard';
import { AppService } from './app.service';
import { LoggedInGuard } from './logged-in.guard';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
publicRoute() {
return this.appService.getPublicMessage();
}
@UseGuards(LoggedInGuard)
@Get('protected')
guardedRoute() {
return this.appService.getPrivateMessage();
}
@UseGuards(AdminGuard)
@Get('admin')
getAdminMessage() {
return this.appService.getAdminMessage();
}
}
// src/app.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getPublicMessage(): string {
return 'This message is public to all!';
}
getPrivateMessage(): string {
return 'You can only see this if you are authenticated';
}
getAdminMessage(): string {
return 'You can only see this if you are an admin';
}
}
// src/main.ts
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const bootstrap = async () => {
const app = await NestFactory.create(AppModule);
const logger = app.get(Logger);
await app.listen(3000);
logger.log(`Application listening at ${await app.getUrl()}`);
};
bootstrap();
测试流程
现在,我们可以一起运行所有程序并测试流程了。首先,确保 Redis 实例正在运行。否则,服务器将无法启动。运行后,运行以下命令nest start --watch
以开发模式启动服务器,服务器会在文件更改时重新编译并重启。现在是时候发送一些curl
数据了。
测试现有用户
那么,让我们从一些现有用户测试开始。我们将尝试以 Joe Foo 的身份登录。
curl http://localhost:3000/auth/login -d 'email=joefoo@test.com&password=Passw0rd!' -c cookie.joe.txt
如果您不熟悉 curl,请将-d
请求发送为 POST,并发送application/x-www-form-urlencoded
Nest 默认接受的数据。这-c
会告诉 curl 启动 Cookie 引擎并将 Cookie 保存到文件中。如果一切顺利,您应该会收到类似这样的响应
{"cookie":{"originalMaxAge":60000,"expires":"2021-08-16T05:30:51.621Z","httpOnly":false,"path":"/","sameSite":true},"passport":{"user":1}}
现在我们可以发送请求/protected
并得到受保护的响应
curl http://localhost:3000/protected -b cookie.joe.txt
我们-b
告诉 curl 使用在这个文件中找到的 cookie。
现在让我们检查注册情况:
curl http://localhost:3000/auth/register -c cookie.new.txt -d 'email=new.email@test.com&password=password&confirmationPassword=password&firstName=New&lastName=Test'
您会注意到没有为新用户创建会话,这意味着他们仍然需要登录。现在让我们发送登录请求
curl http://localhost:3000/auth/login -c cookie.new.txt -d 'email=new.email@test.com&password=password'
并检查我们确实创建了一个会话
curl http://localhost:3000/protected -b cookie.new.txt`
就这样,我们使用 NestJS、Redis 和 Passport 实现了会话登录。
要查看 Redis 中的会话 ID,可以使用 redis-cli 连接到正在运行的实例,然后运行KEYS *
以获取所有已设置的键。默认情况下,connect-redis
用作sess:
会话键前缀。
结论
呼,好吧,这篇文章确实比我预想的要长,而且更深入地探讨了 Nest 与 Passport 的集成,但希望它能帮助大家了解所有组件是如何结合在一起的。有了以上内容,只要用户对象保持不变,会话应该能够与任何类型的登录(基本登录、本地登录、OAuth2.0)集成。
最后需要注意的是,使用 session 时,cookie 是必须的。客户端必须能够使用 cookie,否则 session 每次请求都会丢失。
如果您有任何疑问,请随时发表评论或在NestJS Discord 服务器上找到我
文章来源:https://dev.to/nestjs/setting-up-sessions-with-nestjs-passport-and-redis-210