Аутентификация

Authentication

Аутентификация является неотъемлемой частью большинства приложений. Есть много разных подходов и стратегий для реализации аутентификации. Подход, принятый для любого проекта, зависит от конкретных требований заказчика и/или технического задания. В этой главе представлено несколько подходов к аутентификации, которые могут быть адаптированы к различным требованиям.

Passport - самая популярная библиотека аутентификации node.js, хорошо известная сообществу и успешно используемая во многих приложениях. Интегрировать эту библиотеку с приложением Nest просто с помощью модуля @nestjs/passport. Passport выполняет различные функции:

  • Аутентификация пользователя путем проверки его «учетных данных» (такие как имя пользователя/пароль, JSON Web Token (JWT) или токен идентификации от провайдера идентификации)

  • Управление состоянием аутентификации(путем выдачи переносимого токена, такого как JWT, или создания Express-сессии)

  • Прикрепление информации об аутентифицированном пользователе к объекту Request для дальнейшего использования в обработчиках маршрута.

Passport имеет богатую экосистему стратегий, которые реализуют различные механизмы аутентификации. Несмотря на простоту концепции, набор стратегий Passport, которые вы можете выбрать, очень велик и отличается большим разнообразием. Паспорт абстрагирует эти разнообразные шаги в стандартный шаблон, а модуль @nestjs/passport объединяет и стандартизирует этот шаблон в знакомые конструкции Nest.

В этой главе мы реализуем комплексное решение сквозной аутентификации для сервера API RESTful с использованием этих мощных и гибких модулей. Вы можете использовать описанные здесь концепции для реализации любой стратегии Passport для настройки вашей схемы аутентификации. Вы можете выполнить шаги, описанные в этой главе, чтобы построить этот полный пример. Вы можете найти репозиторий с готовым примером приложения здесь.

Требования к аутентификации

Давайте уточним наши требования. В этом примере клиенты будут проходить аутентификацию с именем пользователя и паролем. После аутентификации сервер выдаст JWT, который может быть отправлен в качестве маркера-носителя в заголовке авторизации при последующих запросах для подтверждения аутентификации. Мы также создадим защищенный маршрут, который будет доступен только для запросов, содержащих действительный JWT.

Сначала нам нужно установить необходимые пакеты. Passport предоставляет стратегию под названием passport-local, которая реализует механизм аутентификации по имени пользователя и паролю, который соответствует нашим потребностям в этой части нашего примера.

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

Для любой выбранной вами стратегии Passport вам всегда понадобятся пакеты @nestjs/passport и passport. Затем вам нужно установить пакет для конкретной стратегии (например, passport-jwt или passport-local), который реализует конкретную стратегию аутентификации, которую вы создаете. Кроме того, вы также можете установить определения типов для любой стратегии Passport, как показано выше для @types/passport-local, которая предоставляет помощь при написании типизированного кода на TypeScript.

Реализация Passport-стратегий

Теперь мы готовы реализовать функцию аутентификации. Мы начнем с обзора процесса, используемого для любой Passport стратегии. Полезно думать о Passport как о мини-фреймворке. Элегантность фреймворка заключается в том, что он абстрагирует процесс аутентификации в несколько основных шагов, которые вы настраиваете на основе стратегии, которую вы реализуете. Это похоже на фреймворк, потому что вы настраиваете его, предоставляя параметры настройки (в виде простых объектов JS) и пользовательский код в виде функций обратного вызова(callback function), которые Passport вызывает в соответствующее время. Модуль @nestjs/passport оборачивает этот минифреймворк в Nest-подобном стиле, упрощая интеграцию в приложение Nest. Мы будем использовать @nestjs/passport ниже, но сначала давайте рассмотрим, как работает vanilla Passport.

В Vanilla Passport вы настраиваете стратегию, предоставляя две вещи:

  1. Набор параметров, специфичных для этой стратегии. Например, в стратегии JWT вы можете передать секрет для подписи токенов.

  2. «Подтверждающий обратный вызов»(verify callback), в котором вы сообщаете Passport, как взаимодействовать с вашим хранилищем пользователей (где вы управляете учетными записями пользователей). Здесь вы проверяете, существует ли пользователь (и/или создаете ли нового пользователя), и действительны ли его учетные данные. Библиотека Passport ожидает, что этот обратный вызов вернет полного пользователя, если проверка прошла успешно, или ноль, если она завершится неудачей (ошибка определяется как то, что пользователь не найден, или, в случае passport-local, пароль не совпадает)

С помощью @nestjs/passport вы настраиваете стратегию Passport, расширяя класс PassportStrategy. Вы передаете параметры стратегии(пункт 1 выше), вызывая метод super() в своем подклассе, при необходимости передавая объект параметров. Вы предоставляете обратный вызов проверки (пункт 2 выше), реализуя метод validate() в своем подклассе.

Мы начнем с генерации AuthModule и в нем AuthService через CLI:

$ nest g module auth
$ nest g service auth

Реализуя AuthService, мы обнаружим, что полезно инкапсулировать пользовательские операции в UsersService, поэтому давайте сейчас сгенерируем эти модуль и сервис:

$ nest g module users
$ nest g service users

Замените содержимое этих файлов по умолчанию, как показано ниже. Для нашего примера приложения UsersService просто поддерживает жестко запрограммированный список пользователей в памяти и метод find для его получения по имени пользователя. В реальном приложении именно здесь вы должны построить свою модель пользователя и уровень персистентности, используя предпочитаемую библиотеку (например, TypeORM, Sequelize, Mongoose и т.д.).

users/users.service.ts

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

export type User = any;

@Injectable()
export class UsersService {
  private readonly users: User[];

  constructor() {
    this.users = [
      {
        userId: 1,
        username: 'john',
        password: 'changeme',
      },
      {
        userId: 2,
        username: 'chris',
        password: 'secret',
      },
      {
        userId: 3,
        username: 'maria',
        password: 'guess',
      },
    ];
  }

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

В модуле UsersModule единственное необходимое изменение - добавить UsersService в массив экспорта декоратора @Module, чтобы он был виден за пределами этого модуля (вскоре мы будем использовать его в нашем AuthService).

users/users.module.ts

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Наш сервис AuthService занимается поиском пользователя и проверкой пароля. Для этого мы создаем метод validateUser(). В приведенном ниже коде мы используем удобный оператор ES6 spread для удаления свойства пароля из объекта пользователя перед его возвратом. Мы будем вызывать метод validateUser() из нашей локальной стратегии Passport в ближайшее время.

auth/auth.service.ts

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@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 или argon2, применяя алгоритм хеширования с солью. При таком подходе вы сохраняете только хешированные пароли, а затем сравниваете сохраненный пароль с хешированной версией входящего пароля, таким образом, никогда не сохраняя и не раскрывая пароли пользователей в виде простого текста. Для простоты нашего примера приложения мы нарушаем этот абсолютный мандат и используем простой текст. Не делай этого в своем настоящем приложении!

Теперь мы обновляем наш AuthModule для импорта UsersModule.

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

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

Реализация Pasport local

Теперь мы можем реализовать нашу стратегию аутентификации Passport-local. Создайте файл с именем local.strategy.ts в папке auth и добавьте следующий код:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@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;
  }
}

Мы следовали рецепту, описанному ранее для всех стратегий Passport. В нашем случае использования с passport-local нет никаких опций конфигурации, поэтому наш конструктор просто вызывает super(), без объекта options.

Мы также реализовали метод validate(). Для каждой стратегии Passport будет вызывать функцию проверки (реализованную с помощью метода validate() в @nestjs/passport) с использованием соответствующего набора параметров для конкретной стратегии. Для локальной стратегии Passport ожидается метод validate() со следующей сигнатурой: validate(username: string, password:string): any.

Большая часть работы по проверке выполняется в нашем AuthService (с помощью нашего UserService), поэтому этот метод довольно прост. Метод validate() для любой стратегии Passport будет следовать аналогичному шаблону, варьируя только детали представления учетных данных. Если пользователь найден и учетные данные действительны, он(user) возвращается, чтобы Passport мог выполнить свои задачи (например, создать свойство пользователя в объекте Request), и конвейер обработки запросов может продолжаться. Если он не найден, мы генерируем исключение, и наш слой исключений обрабатывает его

Как правило, единственным существенным отличием метода validate() для каждой стратегии является то, как вы определяете, существует ли пользователь и является ли он действительным. Например, в стратегии JWT, в зависимости от требований, мы можем оценить, соответствует ли userId, содержащийся в декодированном токене, записи в нашей базе данных пользователей или соответствует списку отозванных токенов. Следовательно, эта схема подкласса и реализации валидации для конкретной стратегии является последовательной, элегантной и расширяемой.

Нам нужно настроить наш AuthModule для использования только что определенных функций Passport. Обновите auth.module.ts, чтобы он выглядел так:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

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

Встроенные Passport Guards

Глава Guards описывает основную функцию Guards: определить, будет ли запрос обрабатываться обработчиком маршрута или нет. Это остается верным, и мы скоро будем использовать эту стандартную возможность. Однако в контексте использования модуля@nestjs/passportмы также представим небольшую новую морщинку, которая поначалу может сбить с толку, поэтому давайте обсудим это сейчас. Учтите, что ваше приложение может существовать в двух состояниях с точки зрения аутентификации:

  1. пользователь/клиент не залогинен (не аутентифицирован)

  2. пользователь/клиент залогинен (аутентифицирован)

В первом случае (пользователь не авторизован) нам нужно выполнить две разные функции:

  1. Ограничить маршруты, к которым может получить доступ неаутентифицированный пользователь (то есть запретить доступ к ограниченным маршрутам). Мы будем использовать Guards в их привычном качестве, чтобы справиться с этой функцией, разместив Guard на защищенных маршрутах. Как вы можете ожидать, мы будем проверять наличие действующего JWT в этом Guard, поэтому мы будем работать над этой Guard позже, как только мы успешно выпустим JWT.

  2. Инициируйте сам шаг аутентификации, когда ранее не прошедший аутентификацию пользователь пытается войти в систему. На этом шаге мы выдадим JWT действительному пользователю. Подумав немного об этом, мы знаем, что нам потребуется POST-запрос с учетными данными (имени пользователя/пароль), чтобы инициировать аутентификацию, поэтому мы настроим маршрут POST /auth/login для этого. Возникает вопрос: как именно мы задействуем стратегию passport-local на этом маршруте?

Ответ прост: с помощью другого, немного отличающегося типа Guard. Модуль @nestjs/passport предоставляет нам встроенную защиту, которая делает это за нас. Этот Guard вызывает стратегию Passport и запускает шаги, описанные выше (получение учетных данных, запуск функции проверки, создание пользовательского свойства и т.д.).

Второй случай, перечисленный выше (зарегистрированный пользователь), просто основан на стандартном типе Guard, который мы уже обсуждали, чтобы разрешить доступ к защищенным маршрутам для зарегистрированных пользователей.

Маршрут логина

Теперь мы можем реализовать маршрут auth/login и применить встроенный Guard для инициализации passport-local.

Откройте файл app.controllers.ts и замените его следующим содержимым:

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) {
    return req.user;
  }
}

С помощью@UseGuards (AuthGuard ('local')) мы используем AuthGuard, который @nestjs/passport автоматически предоставил нам, когда мы расширили стратегию passport-local. Давайте разберемся с этим. Наша локальная стратегия Passport имеет имя по умолчанию 'local'. Мы ссылаемся на это имя в декораторе @UseGuards(), чтобы связать его с кодом, предоставленным пакетом passport-local. Это используется для устранения неоднозначности, какую стратегию вызывать в случае, если в нашем приложении есть несколько стратегий Passport (каждая из которых может предусматривать AuthGuard для конкретной стратегии). Пока у нас есть только одна такая стратегия, мы вскоре добавим вторую, так что это необходимо для устранения неоднозначности.

Чтобы протестировать наш маршрут /auth/login, просто верните пользователя на данный момент. Это также позволяет нам продемонстрировать еще одну функцию Passport:

Passport автоматически создает объект user на основе значения, которое мы возвращаем из метода validate(), и назначает его объекту Request как req.user. Позже мы заменим это кодом для создания и возврата JWT.

Поскольку это API-маршруты, мы протестируем их с помощью общедоступной библиотеки cURL. Вы можете протестировать любой объект user, захардкоженный в UsersService.

$ # 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"}

Пока это работает, передача имени стратегии непосредственно в AuthGuard() вводит волшебные строки в кодовую базу. Вместо этого мы рекомендуем создать свой собственный класс, как показано ниже:

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

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

Теперь мы можем обновить обработчик маршрута /auth/login и использовать вместо него LocalAuthGuard:

@UseGuards(LocalAuthGuard)
@Post('auth/login')
async login(@Request() req) {
  return req.user;
}

Функциональность JWT

Мы готовы перейти к JWT-части нашей системы аутентификации. Давайте рассмотрим и уточним наши требования:

  • Разрешить пользователям аутентифицироваться с помощью имени пользователя/пароля, возвращая JWT для использования в последующих вызовах защищенных конечных точек API. Мы уже далеко продвинулись к тому, чтобы выполнить это требование. Чтобы завершить его, нам нужно будет написать код, который выдает JWT.

  • Создание маршрутов API, защищенных на основе наличия действительного JWT в качестве токена носителя

Нам нужно будет установить еще пару пакетов для поддержки наших требований к JWT:

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

Пакет @nests/jwt (см.подробнее здесь) - это служебный пакет, который помогает при манипулировании JWT. Пакет passport-jwt - это пакет Passport, который реализует стратегию JWT, а @types/passport-jwt предоставляет определения типов TypeScript.

Давайте подробнее рассмотрим, как обрабатывается запрос POST /auth/login. Мы обернули в декоратор маршрут, используя встроенный AuthGuard, предоставленный стратегией passport-local. Это означает, что:

  1. Обработчик маршрута будет вызван только в том случае, если пользователь был проверен

  2. Параметр req будет содержать свойство user (заполняется Passport в процессе аутентификации со стратегией passport-local)

Имея это в виду, теперь мы можем, наконец, создать настоящий JWT и вернуть его по этому маршруту. Чтобы сохранить наши сервисы чисто модульными, мы будем обрабатывать генерацию JWT в authService. Откройте файлauth.service.ts в папке auth, добавьте метод login() и импортируйте JwtService, как показано:

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
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),
    };
  }
}

Мы используем библиотеку @nestjs/jwt, которая предоставляет функцию sign() для создания нашего JWT из подмножества свойств объекта user, которые мы затем возвращаем как простой объект с одним свойством access_token.

Примечание: мы выбираем имя свойства sub, чтобы наше значение userId соответствовало стандартам JWT. Не забудьте заинъектить провайдер JwtService в AuthService.

Теперь нам нужно обновить AuthModule для импорта новых зависимостей и настроить JwtModule.

Во-первых, создайте constants.ts в папке auth и добавьте следующий код:

auth/constants.ts

export const jwtConstants = {
  secret: 'secretKey',
};

Не выставляйте этот ключ на всеобщее обозрение. Мы сделали это здесь, чтобы прояснить, что делает код, но в production вы должны защитить этот ключ с помощью соответствующих мер, таких как хранилище ключей, переменная среды или служба конфигурации.

Теперь откройте auth.module.ts в папке auth и обновите файл таким образом:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy],
  exports: [AuthService],
})
export class AuthModule {}

Мы настраиваем JwtModule с помощью register(), передавая в него объект конфигурации. Смотрите здесь более подробно о NestJwtModule и здесь для получения более подробной информации о доступных параметрах конфигурации.

Теперь мы можем обновить маршрут /auth/login, чтобы вернуть JWT.

app.controller.ts

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

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }
}

Давайте пойдем вперед и снова протестируем наши маршруты с помощью cURL.

Вы можете протестировать любой из пользовательских объектов, жестко закодированных в UsersService.

$ # 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

Реализация Passport JWT

Теперь мы можем решить наше последнее требование: защитить конечные точки, требуя, чтобы в запросе присутствовал действительный JWT. Passport может помочь нам и здесь. Он предоставляет стратегию passport-jwt для обеспечения безопасности конечных точек RESTful с помощью веб-токенов JSON. Начните с создания файла под названием jwt.strategy.ts в папке auth и добавьте следующий код:

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

@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 };
  }
}

В нашей JwtStrategy мы следовали одному и тому же рецепту, описанному ранее для всех Passport стратегий. Эта стратегия требует некоторой инициализации, поэтому мы делаем это, передавая объект options в вызове super(). Подробнее о доступных опциях вы можете прочитать здесь. В нашем случае эти варианты таковы:

  • jwtFromRequest: предоставляет метод, с помощью которого JWT будет извлечен из запроса. Мы будем использовать стандартный подход предоставления bearer token в заголовке Authorization наших запросов API. Другие варианты описаны здесь.

  • ignoreExpiration: просто чтобы быть явным, мы выбираем значение false по умолчанию, которое делегирует ответственность за то, чтобы срок действия JWT не истек, модулю Passport. Это означает, что если наш маршрут вызовется с истекшим JWT, запрос будет отклонен и отправлен ответ 401 Unauthorized. Passport удобно справляется с этим автоматически для нас.

  • secretOrKey: мы используем целесообразный вариант предоставления симметричного секрета для подписания токена. Другие параметры, такие как открытый ключ в кодировке PEM, могут быть более подходящими для приложений в production(см. здесь дополнительную информацию). Ни в коем случае, как предупреждали ранее, не раскрывайте этот секрет.

Метод validate() заслуживает некоторого обсуждения. Для JWT-стратегии, Passport сначала проверяет подпись JWT и декодирует JSON. Затем он вызывает наш метод validate(), передавая декодированный JSON в качестве его единственного параметра. Основываясь на том, как работает подпись JWT, мы гарантируем, что получаем действительный токен, который мы ранее подписали и выдали действительному пользователю.

В результате всего этого наш ответ на колбэкvalidate() тривиален: мы просто возвращаем объект, содержащий свойства userId и username. Напомним еще раз, что Passport построит объект пользователя на основе возвращаемого значения нашего метода validate() и прикрепит его в качестве свойства к объекту запроса.

Стоит также отметить, что этот подход оставляет нам место (так сказать, "хуки") для внедрения в процесс другой бизнес-логики. Например, мы можем выполнить поиск по базе данных в нашем методе validate(), чтобы извлечь дополнительную информацию о пользователе, в результате чего в нашем Request будет доступен более обогащенный user объект. Это также место, где мы можем решить провести дополнительную проверку токенов, например поиск идентификатора пользователя в списке отозванных токенов, что позволит нам выполнить отзыв токенов. Модель, которую мы реализовали здесь в нашем примере кода, - это быстрая "JWT stateless" модель, где каждый вызов API немедленно авторизуется на основе наличия действительного JWT, и небольшой бит информации о запрашивающем (его userId и username) доступен в нашем конвейере запросов.

Добавьте новую JwtStrategy в качестве поставщика в AuthModule:

auth/auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

Импортируя тот же секрет, который использовался при подписании JWT, мы гарантируем, что фаза проверки, выполняемая Passport, и фаза подписи, выполняемая в нашем AuthService, используют общий секрет.

Наконец, мы определяем класс JwtAuthGuard, который расширяет встроенный AuthGuard:

auth/jwt-auth.guard.ts

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

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

Реализация защищенного маршрута и JWT strategy guards

Теперь мы можем реализовать наш защищенный маршрут и связанныйс ним Guard.

Откройте app.controller.ts файл и обновить его, как показано ниже:

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

@Controller()
export class AppController {
  constructor(private authService: AuthService) {}

  @UseGuards(LocalAuthGuard)
  @Post('auth/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

В очередной раз мы применяем AuthGuard, который модуль @nests/passport автоматически подготовил для нас, когда мы настроили модуль passport-jwt. На этот Guard ссылается его имя по умолчанию, jwt. Когда вы попадете в маршрут GET /profile, Guard автоматически вызовет вашу пользовательскую логику passport-jwt, проверяя JWT и назначая свойство user объекту Request.

Убедитесь, что приложение работает, и протестируйте маршруты с помощью cURL.

$ # GET /profile
$ curl http://localhost:3000/profile
$ # result -> {"statusCode":401,"error":"Unauthorized"}

$ # 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"}

Обратите внимание, что в AuthModule мы настроили JWT так, чтобы он имел срок действия 60 секунд. Это, вероятно, слишком короткий срок действия, и рассмотрение деталей истечения срока действия токена и обновления выходит за рамки этой статьи. Однако мы выбрали это, чтобы продемонстрировать важное качество JWT и стратегии passport-jwt. Если вы подождете 60 секунд после проверки подлинности перед попыткой запроса GET /profile, вы получите 401 Unauthorized ответ. Это происходит потому, что Passport автоматически проверяет JWT на время его истечения, избавляя вас от необходимости делать это в вашем приложении.

В итоге мы реализовали реализацию аутентификации с помощью JWT. Клиенты JavaScript (такие как Angular/React/Vue) и другие приложения JavaScript теперь могут аутентифицироваться и безопасно взаимодействовать с нашим API-сервером. Вы можете найти полную версию кода в этой главе здесь.

Стратегия по умолчанию

В нашем AppController мы передаем имя стратегии в декораторе @AuthGuard(). Мы должны сделать это, потому что мы ввели две паспортные стратегии (passport-local и passport-jwt), обе из которых обеспечивают реализацию различных компонентов passport. Передача имени устраняет двусмысленность, с какой реализацией мы связываемся. Когда в приложение включено несколько стратегий, мы можем объявить стратегию по умолчанию, чтобы нам больше не нужно было передавать имя в декораторе @AuthGuard, если мы используем эту стратегию по умолчанию. Вот как зарегистрировать стратегию по умолчанию при импорте модуля Passport. Этот код будет в AuthModule:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
    UsersModule,
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

Стратегии области действия запроса(Request-scoped strategies)

API passport основан на регистрации стратегий в глобальном экземпляре библиотеки. Поэтому стратегии не предназначены для того, чтобы иметь параметры, зависящие от запроса, или динамически создаваться для каждого запроса (подробнее о провайдерах с областью действия запроса). Когда вы настраиваете свою стратегию на область запросов, Nest никогда не будет создавать ее экземпляр, поскольку она не привязана к какому-либо конкретному маршруту. Нет никакого физического способа определить, какие стратегии "области запроса" должны выполняться для каждого запроса.

Однако в рамках стратегии существуют способы динамического разрешения проблем провайдеров с областью запросов. Для этого мы используем module reference фичу.

Во-первых, откройте local.strategy.ts и внедрите ModuleRef в обычном режиме:

constructor(private moduleRef: ModuleRef) {
  super({
    passReqToCallback: true,
  });
}

Класс ModuleRef импортируется из пакета @nestjs/core

Обязательно установите для свойства конфигурации passReqToCallback значение true, как показано выше.

На следующем шаге экземпляр запроса будет использоваться для получения текущего идентификатора контекста, а не для создания нового (Подробнее о контексте запроса читайте здесь).

Теперь внутри метода validate() класса LocalStrategy используйте метод getByRequest() класса ContextIdFactory, чтобы создать идентификатор контекста на основе объекта запроса и передать его вызову resolve():

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);
  ...
}

В приведенном выше примере метод resolve() асинхронно возвращает экземпляр провайдера AuthService с областью запроса (мы предположили, что AuthService помечен как провайдер с областью запроса).

Расширение защитников(Extending guards)

В большинстве случаев достаточно использовать предоставленный класс AuthGuard. Однако могут быть случаи использования, когда вы хотели бы просто расширить стандартную логику обработки ошибок или аутентификации. Для этого можно расширить встроенный класс и переопределить методы внутри подкласса.

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, user, info) {
    // You can throw an exception based on either "info" or "err" arguments
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}

Настройка Passport

Любые стандартные параметры настройки Passport могут быть переданы таким же образом, используя метод register(). Доступные варианты зависят от реализуемой стратегии. Например:

PassportModule.register({ session: true });

Вы также можете передать стратегии объект опций в их конструкторы, чтобы настроить их. Для локальной стратегии вы можете например:

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

Взгляните на официальный сайт Passport для получения имен свойств.

Именованные стратегии

При реализации стратегии вы можете указать ее имя, передав второй аргумент функции PassportStrategy. Если вы этого не сделаете, то каждая стратегия будет иметь имя по умолчанию (например, "jwt" для jwt-стратегии):

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

Теперь вы ссылаетесь на это с помощью декоратора, такого как @AuthGuard('my jwt').

GraphQL

Чтобы использовать AuthGuard с GraphQL, расширьте встроенный класс AuthGuard и переопределите метод getRequest().

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

Чтобы использовать приведенную выше конструкцию, обязательно передайте объект запроса (req) как часть значения контекста в настройках модуля GraphQL:

GraphQLModule.forRoot({
  context: ({ req }) => ({ req }),
});

Чтобы получить текущего аутентифицированного пользователя в вашем graphql резолвере, вы можете определить декоратор @CurrentUser():

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;
  },
);

Чтобы использовать вышеуказанный декоратор в вашем резолвере, обязательно включите его в качестве параметра вашего запроса или мутации:

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

Last updated