在 Nest 中使用 Passport:从用户名密码登录到 JWT 路由保护

在 Nest 中使用 Passport:从用户名密码登录到 JWT 路由保护

原文:NestJS 官方文档贡献者,Passport (authentication)。中文翻译与技术整理:未完纪。核对日期:2026-10-08。对照 Nest 官方文档仓库的 Passport 原始 Markdown;该 URL 指向可变 master 分支,未固定 commit。为避免重复,统一保留 TypeScript 分支,省去等价的 JavaScript 切换示例。来源仓库根许可证为 MIT,全文随稿附上。

Passport 将认证过程归纳为三件事:核验用户名密码、JWT 或身份提供商令牌;通过令牌或会话管理认证状态;把认证后的用户信息附加到请求对象。它有丰富的策略生态,@nestjs/passport 将策略包装成 Nest 熟悉的依赖注入、模块和守卫。本文串起完整的 REST API 认证过程,并说明如何扩展这套结构。

本地策略验证用户名密码后签发JWT,后续请求由JWT策略校验后进入受保护路由
认证与访问分成两个阶段;未完纪原创示意图,根据本文流程绘制,不是运行截图。

先明确认证流程

客户端先把用户名和密码发给登录端点。验证成功,服务器签发 JWT;后续请求在 Authorization 头中以 Bearer 方式携带它。受保护路由只处理带有效 JWT 的请求。先实现本地策略,再加令牌签发,最后接上保护路由。

$ npm install --save @nestjs/passport passport passport-local
$ npm install --save-dev @types/passport-local

无论选择哪一种 Passport 策略,都需要 @nestjs/passport 和 passport;另外还要安装实现该策略的包,以及需要的 TypeScript 类型声明。原生 Passport 接收策略选项和 verify 回调。Nest 中的写法是继承 PassportStrategy,在构造函数中通过 super(options) 传配置,并用 validate() 实现校验回调。成功时返回用户;用户不存在或凭据不符时返回失败或抛出异常。

建立用户服务与认证服务

$ nest g module auth
$ nest g service auth
$ nest g module users
$ nest g service users

UsersService 封装用户读取。原文为了让例子自包含,把两个用户放在内存数组中;实际项目应在这一层接入数据库和用户实体。下面两组账户和密码都是公开演示数据,不能作为真实账户配置。

import { Injectable } from '@nestjs/common';
// This should be a real class/interface representing a user entity
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    {
      userId: 1,
      username: 'john',
      password: 'changeme',
    },
    {
      userId: 2,
      username: 'maria',
      password: 'guess',
    },
  ];
  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

在 users/users.module.ts 中导出 UsersService,AuthModule 才能注入它。

import { Module } from '@nestjs/common';
import { UsersService } from './users.service.js';
@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

AuthService 的 validateUser() 查询用户并核对密码。验证成功后,用对象剩余语法移除 password,避免它跟随认证结果进入 req.user 或响应。

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

原文安全边界:这里把密码以明文保存,并使用 === 比较,只为解释控制流程。真实项目应保存带盐单向密码哈希,使用 bcrypt 等专门库的验证接口核对输入,不能把“对输入重新哈希后比较字符串”当作所有密码哈希格式通用的验证方法。还应按自身环境补充登录频率限制、输入校验和安全传输。本文没有实现这些生产控制,也没有执行示例服务。

接着让 AuthModule 导入 UsersModule。

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { UsersModule } from '../users/users.module.js';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
})
export class AuthModule {}

实现 local 策略

在 auth/local.strategy.ts 中继承 passport-local 的 Strategy。默认从请求体读取 username 和 password,因此 super() 不必带额外选项。若希望用邮箱字段,可传入 super({ usernameField: ’email’ })。validate(username, password) 调用前面建立的 AuthService;失败时抛出 UnauthorizedException,让 Nest 异常层处理。成功返回的对象最终成为 req.user。

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service.js';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }
  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

这一模式适用于其他策略,只是凭据形式不同。JWT 策略也可以在 validate() 中检查用户是否还存在,或者查询撤销记录;这些都是业务附加检查,并不是 Passport 自动替你实现的能力。把 PassportModule 加入 imports,把 LocalStrategy 加入 providers,策略才会被注册。

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy.js';
@Module({
  imports: [UsersModule, PassportModule],
  providers: [AuthService, LocalStrategy],
})
export class AuthModule {}

用守卫启动登录

守卫既能阻止未认证请求进入受限路由,也能启动认证。POST /auth/login 使用 AuthGuard(‘local’),触发读取凭据、调用验证函数和填充 req.user。这里的 local 是 passport-local 的默认策略名,不是路由名。先让控制器返回用户,检查认证链路是否接通。

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Controller()
export class AppController {
  @UseGuards(AuthGuard('local'))
  @Post('auth/login')
  async login(@Request() req: any) {
    return req.user;
  }
}
$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"userId":1,"username":"john"}

上面的响应只是原文展示的预期结果,不是本轮测试输出。为了避免到处散落策略名称字符串,建立有名字的 LocalAuthGuard,随后在路由上使用这个类。

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req: any) {
  return req.user;
}

会话注销与 JWT 的边界

原文还给出了 req.logout() 的会话注销分支。它依赖 express-session、passport.initialize() 和 passport.session(),而本教程并未配置这些中间件。原文在该路由上使用 LocalAuthGuard,这会重新校验用户名和密码,并不等于验证已有会话;因此下面保留的是原文示例,不是可直接复制的会话注销端点。真实应用应按已配置的会话认证和路由策略改造。Passport 0.6 起 logout() 需要回调,可包成 Promise。它只清除会话,不会撤销已经签发的 JWT。

@UseGuards(LocalAuthGuard)
@Post('auth/logout')
async logout(@Request() req: any) {
  await new Promise<void>((resolve, reject) =>
    req.logout((err: any) => (err ? reject(err) : resolve())),
  );
}

签发 JWT

用户名密码验证成功后,登录处理器才会执行,req.user 也已经存在。安装 @nestjs/jwt、passport-jwt 和其类型声明,然后把令牌签发集中放在 AuthService。@nestjs/jwt 提供令牌处理工具,passport-jwt 负责请求认证策略。

$ npm install --save @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

注入 JwtService 并加入 login()。载荷只选取 username 和 userId,将 userId 放入标准 sub 声明。返回对象只有 access_token 字段。注意 JWT 的载荷不等于秘密存储空间,不能把用户密码等敏感数据放进去。

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async validateUser(username: string, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user && user.password === pass) {
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}

原文 auth/constants.ts 使用下面这条公开固定字符串,使签发和验证引用同一密钥。它是一个明确写着“不要使用”的占位值,而不是安全密钥。直接照抄仍会形成可预测的签名配置。

export const jwtConstants = {
  secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

编辑修订:下面替代 constants.ts 的版本从运行环境读取密钥,并在缺失时停止启动,不提供默认固定值。密钥必须由部署环境或秘密管理服务提供;这段修订没有替代密钥生成、轮换与保管流程,也未做运行测试。

const secret = process.env.JWT_SECRET;
if (!secret) {
  throw new Error('JWT_SECRET must be configured');
}
export const jwtConstants = { secret };

JwtModule.register() 配置签名密钥及有效期。原文将有效期设为 60 秒,方便观察过期拒绝。AuthModule 导出 AuthService,以便上层控制器使用。这里与后面的 JwtStrategy 必须读取同一配置。

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LocalStrategy } from './local.strategy.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants.js';
@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService],
})
export class AuthModule {}

登录端点现在返回 this.authService.login(req.user),而不是直接把用户对象发回去。

import { Controller, Request, Post, UseGuards } from '@nestjs/common';
import { LocalAuthGuard } from './auth/local-auth.guard.js';
import { AuthService } from './auth/auth.service.js';

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}
  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req: any) {
    return this.authService.login(req.user);
  }
}
$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

示例令牌被省略号截断,不能复制为可用 Bearer 令牌。请求命令采用类 Unix shell 的引号形式;在其他命令解释器中应按其规则传递 JSON。

验证 JWT 并保护 profile

创建 auth/jwt.strategy.ts。jwtFromRequest 指定从 Authorization: Bearer 中取令牌;ignoreExpiration: false 保持过期校验,过期会产生 401;secretOrKey 提供验签所需的密钥。这里使用对称签名,其他部署也可按策略支持的选项使用 PEM 公钥等方式。

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants.js';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }
  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

Passport 先验证签名、解码载荷并检查有效期,之后才调用 validate(payload)。本例返回 userId 和 username;它们成为 req.user。这只说明令牌通过当前配置的验证,并不自动证明用户此刻仍未禁用,也不等同于资源级授权。可在 validate() 中追加数据库读取和撤销检查。原文还说明可以返回数组:第一个值映射到 user,第二个映射到 req.authInfo。

把 JwtStrategy 加入 AuthModule 的 providers,保留签发与验证共用的密钥。

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { LocalStrategy } from './local.strategy.js';
import { JwtStrategy } from './jwt.strategy.js';
import { UsersModule } from '../users/users.module.js';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants.js';
@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

再建立 JwtAuthGuard,并给 GET /profile 加上这个守卫。根模块应保留生成器建立的 AuthModule 导入,使控制器能使用其导出的 AuthService。

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
import { Controller, Get, Request, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard.js';
import { LocalAuthGuard } from './auth/local-auth.guard.js';
import { AuthService } from './auth/auth.service.js';
@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req: any) {
    return this.authService.login(req.user);
  }
  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req: any) {
    return req.user;
  }
}

原文使用以下请求序列演示结果:不带令牌访问 profile 返回 401;正确登录取得令牌;带令牌访问可得到用户信息。等待超过示例的 60 秒有效期后再次请求,应被拒绝。过期判断由 Passport JWT 策略完成。这是待读者在自己的隔离环境执行的检查序列,本轮未运行。

$ # GET /profile
$ curl http://localhost:3000/profile
$ # result -> {"message":"Unauthorized","statusCode":401}
$ # POST /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
$ # result -> {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm... }

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
$ # result -> {"userId":1,"username":"john"}

60 秒通常不是实际产品的完整令牌策略。刷新、撤销、注销后的处理和权限模型均需另外设计;本例只是无状态访问令牌流程。

扩展守卫与策略链

需要改变认证过程或错误处理时,可继承 AuthGuard 并重写 canActivate()、handleRequest()。原文保留了调用 super.canActivate() 的位置,也提示会话流程可使用 super.logIn(request)。handleRequest() 必须继续拒绝错误或没有用户的情况,不能在扩展时误把失败请求放行。

import {
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Add your custom authentication logic here
    // for example, call super.logIn(request) to establish a session.
    return super.canActivate(context);
  }

  handleRequest(err: any, user: any, info: any) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

AuthGuard 还可以接受策略名称数组。策略依次尝试;第一个成功、重定向或出错的策略会终止链。单个策略的认证失败才继续尝试后面的策略,全部失败后才判定整个认证失败。下列是结构示意,省略号不是可编译实现。

export class JwtAuthGuard extends AuthGuard(['strategy_jwt_1', 'strategy_jwt_2', '...']) { ... }

默认保护全部路由,再声明公开端点

多数端点都需要认证时,可以通过 APP_GUARD 注册全局 JwtAuthGuard。APP_GUARD 从 @nestjs/core 导入。然后用元数据标记公开端点,避免给每个控制器重复添加守卫。

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@Public()
@Get()
findAll() {
  return [];
}

JwtAuthGuard 注入 @nestjs/core 的 Reflector,通过 getAllAndOverride() 先查处理器再查控制器元数据。发现公开标记返回 true,否则继续执行 JWT 验证。下面片段需要与前面的 import 和 IS_PUBLIC_KEY 定义配套使用。

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }
  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }
    return super.canActivate(context);
  }
}

编辑提示:开启全局 JWT 守卫后,登录端点也必须添加 @Public(),同时保留 LocalAuthGuard。Public 只让全局 JWT 守卫跳过该端点,用户名密码校验仍由本地守卫负责,否则用户在拿到令牌之前就会被全局 JWT 守卫挡住。

在策略中使用请求作用域服务

Passport 在全局实例中注册策略,因此策略本身不适合设为请求作用域。它不绑定某条路由,Nest 无法据此决定每个请求应实例化哪一种策略。需要请求作用域服务时,让策略保持可注册的作用域,在 validate() 内动态解析服务。

先从 @nestjs/core 注入 ModuleRef,并把 passReqToCallback 设为 true,让 Passport 将请求对象传给校验函数。随后使用 ContextIdFactory.getByRequest(request) 取得当前请求的上下文标识,再调用 moduleRef.resolve(AuthService, contextId)。不要另建无关上下文,下面省略的部分应继续执行既有验证。

constructor(private moduleRef: ModuleRef) {
  super({
    passReqToCallback: true,
  });
}
async validate(
  request: Request,
  username: string,
  password: string,
) {
  const contextId = ContextIdFactory.getByRequest(request);
  // "AuthService" is a request-scoped provider
  const authService = await this.moduleRef.resolve(AuthService, contextId);
  ...
}

调整 Passport 配置与命名策略

PassportModule.register() 的 defaultStrategy 和 property 由 @nestjs/passport 使用,其余选项传给 Passport authenticate(),具体选项取决于所用策略。session: true 是其中一个配置示例,但单独这一行不会替你建立前述会话中间件。

PassportModule.register({ session: true });

策略自己的选项仍在构造函数中传给 super()。例如把用户名字段改成 email,同时指定密码字段:

constructor(private authService: AuthService) {
  super({
    usernameField: 'email',
    passwordField: 'password',
  });
}

PassportStrategy 的第二个参数可以改策略注册名。使用 myjwt 后,应以 @UseGuards(AuthGuard(‘myjwt’)) 引用它;不传名称时使用底层策略的默认名。

export class JwtStrategy extends PassportStrategy(Strategy, 'myjwt')

在 GraphQL 中取得请求与用户

GraphQL 的执行上下文与 HTTP 控制器不同。继承 AuthGuard 后重写 getRequest(),用 GqlExecutionContext.create(context) 取得 GraphQL 上下文,再返回 ctx.getContext().req。应用的 GraphQL 配置必须实际提供这个 req。

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}

要在 resolver 参数中取得已认证用户,可建立 CurrentUser 参数装饰器,读取同一个 req.user:

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req.user;
  },
);

原文用以下 resolver 展示装饰器组合。这里的 User、usersService.findById() 和 user.id 属于 GraphQL 示例自己的数据模型;前面的 REST JWT 示例返回的是 userId,合并到同一项目时必须统一字段,而不能直接复制后假定存在 id。

@Query(() => User)
@UseGuards(GqlAuthGuard)
whoAmI(@CurrentUser() user: User) {
  return this.usersService.findById(user.id);
}

若 GraphQL 使用 passport-local,还要把 GraphQL 参数放入请求体,供本地策略提取用户名密码。否则策略读不到凭据,会返回未授权。原文示例按下面顺序合并:同名 GraphQL 参数会覆盖已有 body 字段。只应传入预期的登录参数,并在 schema 或验证层约束它们。

@Injectable()
export class GqlLocalAuthGuard extends AuthGuard('local') {
  getRequest(context: ExecutionContext) {
    const gqlExecutionContext = GqlExecutionContext.create(context);
    const gqlContext = gqlExecutionContext.getContext();
    const gqlArgs = gqlExecutionContext.getArgs();

    gqlContext.req.body = { ...gqlContext.req.body, ...gqlArgs };
    return gqlContext.req;
  }
}

来源与审核说明

本文保留官方章节的完整技术路径,合并了重复语言分支,并明确标出密钥读取、全局登录端点和 GraphQL 字段衔接的编辑提示。研究记录以 Nest 12 与 @nestjs/passport 12 为适用背景;当前文档源码没有为示例锁定完整依赖树,安装时仍需核对对应版本。所有代码只做静态阅读,未安装依赖、启动服务器或执行认证请求。

相关官方资料:@nestjs/passport、@nestjs/jwt、passport-jwt 选项。原作者与来源归属保留;Nest 官方文档仓库采用 MIT 许可,版权信息及许可文本见随稿 LICENSE-MIT.txt。示意图为未完纪原创绘制,不是运行截图。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容