Аутентификация
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, которая реализует механизм аутентификации по имени пользователя и паролю, который соответствует нашим потребностям в этой части нашего примера.
Для любой выбранной вами стратегии 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 вы настраиваете стратегию, предоставляя две вещи:
Набор параметров, специфичных для этой стратегии. Например, в стратегии JWT вы можете передать секрет для подписи токенов.
«Подтверждающий обратный вызов»(verify callback), в котором вы сообщаете Passport, как взаимодействовать с вашим хранилищем пользователей (где вы управляете учетными записями пользователей). Здесь вы проверяете, существует ли пользователь (и/или создаете ли нового пользователя), и действительны ли его учетные данные. Библиотека Passport ожидает, что этот обратный вызов вернет полного пользователя, если проверка прошла успешно, или ноль, если она завершится неудачей (ошибка определяется как то, что пользователь не найден, или, в случае
passport-local
, пароль не совпадает)
С помощью @nestjs/passport
вы настраиваете стратегию Passport, расширяя класс PassportStrategy
. Вы передаете параметры стратегии(пункт 1 выше), вызывая метод super()
в своем подклассе, при необходимости передавая объект параметров. Вы предоставляете обратный вызов проверки (пункт 2 выше), реализуя метод validate()
в своем подклассе.
Мы начнем с генерации AuthModule
и в нем AuthService
через CLI:
Реализуя AuthService, мы обнаружим, что полезно инкапсулировать пользовательские операции в UsersService, поэтому давайте сейчас сгенерируем эти модуль и сервис:
Замените содержимое этих файлов по умолчанию, как показано ниже. Для нашего примера приложения UsersService просто поддерживает жестко запрограммированный список пользователей в памяти и метод find для его получения по имени пользователя. В реальном приложении именно здесь вы должны построить свою модель пользователя и уровень персистентности, используя предпочитаемую библиотеку (например, TypeORM, Sequelize, Mongoose и т.д.).
users/users.service.ts
В модуле UsersModule
единственное необходимое изменение - добавить UsersService
в массив экспорта декоратора @Module
, чтобы он был виден за пределами этого модуля (вскоре мы будем использовать его в нашем AuthService
).
users/users.module.ts
Наш сервис AuthService
занимается поиском пользователя и проверкой пароля. Для этого мы создаем метод validateUser()
. В приведенном ниже коде мы используем удобный оператор ES6 spread для удаления свойства пароля из объекта пользователя перед его возвратом. Мы будем вызывать метод validateUser()
из нашей локальной стратегии Passport в ближайшее время.
auth/auth.service.ts
Конечно, в реальном приложении вы не будете хранить пароль в виде простого текста. Вместо этого вы бы использовали библиотеку, подобную bcrypt или argon2, применяя алгоритм хеширования с солью. При таком подходе вы сохраняете только хешированные пароли, а затем сравниваете сохраненный пароль с хешированной версией входящего пароля, таким образом, никогда не сохраняя и не раскрывая пароли пользователей в виде простого текста. Для простоты нашего примера приложения мы нарушаем этот абсолютный мандат и используем простой текст. Не делай этого в своем настоящем приложении!
Теперь мы обновляем наш AuthModule
для импорта UsersModule
.
Реализация Pasport local
Теперь мы можем реализовать нашу стратегию аутентификации Passport-local
. Создайте файл с именем local.strategy.ts
в папке auth
и добавьте следующий код:
Мы следовали рецепту, описанному ранее для всех стратегий 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,
чтобы он выглядел так:
Встроенные Passport Guards
Глава Guards описывает основную функцию Guards: определить, будет ли запрос обрабатываться обработчиком маршрута или нет. Это остается верным, и мы скоро будем использовать эту стандартную возможность. Однако в контексте использования модуля@nestjs/passport
мы также представим небольшую новую морщинку, которая поначалу может сбить с толку, поэтому давайте обсудим это сейчас. Учтите, что ваше приложение может существовать в двух состояниях с точки зрения аутентификации:
пользователь/клиент не залогинен (не аутентифицирован)
пользователь/клиент залогинен (аутентифицирован)
В первом случае (пользователь не авторизован) нам нужно выполнить две разные функции:
Ограничить маршруты, к которым может получить доступ неаутентифицированный пользователь (то есть запретить доступ к ограниченным маршрутам). Мы будем использовать Guards в их привычном качестве, чтобы справиться с этой функцией, разместив Guard на защищенных маршрутах. Как вы можете ожидать, мы будем проверять наличие действующего JWT в этом Guard, поэтому мы будем работать над этой Guard позже, как только мы успешно выпустим JWT.
Инициируйте сам шаг аутентификации, когда ранее не прошедший аутентификацию пользователь пытается войти в систему. На этом шаге мы выдадим JWT действительному пользователю. Подумав немного об этом, мы знаем, что нам потребуется POST-запрос с учетными данными (имени пользователя/пароль), чтобы инициировать аутентификацию, поэтому мы настроим маршрут
POST /auth/login
для этого. Возникает вопрос: как именно мы задействуем стратегию passport-local на этом маршруте?
Ответ прост: с помощью другого, немного отличающегося типа Guard. Модуль @nestjs/passport
предоставляет нам встроенную защиту, которая делает это за нас. Этот Guard вызывает стратегию Passport и запускает шаги, описанные выше (получение учетных данных, запуск функции проверки, создание пользовательского свойства и т.д.).
Второй случай, перечисленный выше (зарегистрированный пользователь), просто основан на стандартном типе Guard, который мы уже обсуждали, чтобы разрешить доступ к защищенным маршрутам для зарегистрированных пользователей.
Маршрут логина
Теперь мы можем реализовать маршрут auth/login и применить встроенный Guard для инициализации passport-local.
Откройте файл app.controllers.ts и замените его следующим содержимым:
С помощью@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.
Пока это работает, передача имени стратегии непосредственно в AuthGuard()
вводит волшебные строки в кодовую базу. Вместо этого мы рекомендуем создать свой собственный класс, как показано ниже:
Теперь мы можем обновить обработчик маршрута /auth/login
и использовать вместо него LocalAuthGuard
:
Функциональность JWT
Мы готовы перейти к JWT-части нашей системы аутентификации. Давайте рассмотрим и уточним наши требования:
Разрешить пользователям аутентифицироваться с помощью имени пользователя/пароля, возвращая JWT для использования в последующих вызовах защищенных конечных точек API. Мы уже далеко продвинулись к тому, чтобы выполнить это требование. Чтобы завершить его, нам нужно будет написать код, который выдает JWT.
Создание маршрутов API, защищенных на основе наличия действительного JWT в качестве токена носителя
Нам нужно будет установить еще пару пакетов для поддержки наших требований к JWT:
Пакет @nests/jwt
(см.подробнее здесь) - это служебный пакет, который помогает при манипулировании JWT. Пакет passport-jwt
- это пакет Passport, который реализует стратегию JWT, а @types/passport-jwt предоставляет определения типов TypeScript.
Давайте подробнее рассмотрим, как обрабатывается запрос POST /auth/login
. Мы обернули в декоратор маршрут, используя встроенный AuthGuard
, предоставленный стратегией passport-local. Это означает, что:
Обработчик маршрута будет вызван только в том случае, если пользователь был проверен
Параметр
req
будет содержать свойствоuser
(заполняется Passport в процессе аутентификации со стратегией passport-local)
Имея это в виду, теперь мы можем, наконец, создать настоящий JWT и вернуть его по этому маршруту. Чтобы сохранить наши сервисы чисто модульными, мы будем обрабатывать генерацию JWT в authService
. Откройте файлauth.service.ts
в папке auth
, добавьте метод login()
и импортируйте JwtService
, как показано:
Мы используем библиотеку @nestjs/jwt,
которая предоставляет функцию sign()
для создания нашего JWT из подмножества свойств объекта user
, которые мы затем возвращаем как простой объект с одним свойством access_token
.
Примечание: мы выбираем имя свойства sub
, чтобы наше значение userId
соответствовало стандартам JWT. Не забудьте заинъектить провайдер JwtService
в AuthService
.
Теперь нам нужно обновить AuthModule
для импорта новых зависимостей и настроить JwtMod
ule.
Во-первых, создайте constants.ts
в папке auth
и добавьте следующий код:
auth/constants.ts
Не выставляйте этот ключ на всеобщее обозрение. Мы сделали это здесь, чтобы прояснить, что делает код, но в production вы должны защитить этот ключ с помощью соответствующих мер, таких как хранилище ключей, переменная среды или служба конфигурации.
Теперь откройте auth.module.ts
в папке auth
и обновите файл таким образом:
Мы настраиваем JwtModule
с помощью register()
, передавая в него объект конфигурации. Смотрите здесь более подробно о NestJwtModule
и здесь для получения более подробной информации о доступных параметрах конфигурации.
Теперь мы можем обновить маршрут /auth/login
, чтобы вернуть JWT.
app.controller.ts
Давайте пойдем вперед и снова протестируем наши маршруты с помощью cURL.
Вы можете протестировать любой из пользовательских объектов, жестко закодированных в UsersService
.
Реализация Passport JWT
Теперь мы можем решить наше последнее требование: защитить конечные точки, требуя, чтобы в запросе присутствовал действительный JWT. Passport может помочь нам и здесь. Он предоставляет стратегию passport-jwt
для обеспечения безопасности конечных точек RESTful с помощью веб-токенов JSON. Начните с создания файла под названием jwt.strategy.ts
в папке auth
и добавьте следующий код:
В нашей 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
Импортируя тот же секрет, который использовался при подписании JWT, мы гарантируем, что фаза проверки, выполняемая Passport, и фаза подписи, выполняемая в нашем AuthService
, используют общий секрет.
Наконец, мы определяем класс JwtAuthGuard
, который расширяет встроенный AuthGuard
:
auth/jwt-auth.guard.ts
Реализация защищенного маршрута и JWT strategy guards
Теперь мы можем реализовать наш защищенный маршрут и связанныйс ним Guard.
Откройте app.controller.ts файл и обновить его, как показано ниже:
В очередной раз мы применяем AuthGuard
, который модуль @nests/passport
автоматически подготовил для нас, когда мы настроили модуль passport-jwt
. На этот Guard ссылается его имя по умолчанию, jwt
. Когда вы попадете в маршрут GET /profile
, Guard автоматически вызовет вашу пользовательскую логику passport-jwt
, проверяя JWT и назначая свойство user
объекту Request
.
Убедитесь, что приложение работает, и протестируйте маршруты с помощью cURL.
Обратите внимание, что в 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
:
Стратегии области действия запроса(Request-scoped strategies)
API passport основан на регистрации стратегий в глобальном экземпляре библиотеки. Поэтому стратегии не предназначены для того, чтобы иметь параметры, зависящие от запроса, или динамически создаваться для каждого запроса (подробнее о провайдерах с областью действия запроса). Когда вы настраиваете свою стратегию на область запросов, Nest никогда не будет создавать ее экземпляр, поскольку она не привязана к какому-либо конкретному маршруту. Нет никакого физического способа определить, какие стратегии "области запроса" должны выполняться для каждого запроса.
Однако в рамках стратегии существуют способы динамического разрешения проблем провайдеров с областью запросов. Для этого мы используем module reference фичу.
Во-первых, откройте local.strategy.ts
и внедрите ModuleRef
в обычном режиме:
Класс ModuleRef импортируется из пакета @nestjs/core
Обязательно установите для свойства конфигурации passReqToCallback
значение true
, как показано выше.
На следующем шаге экземпляр запроса будет использоваться для получения текущего идентификатора контекста, а не для создания нового (Подробнее о контексте запроса читайте здесь).
Теперь внутри метода validate()
класса LocalStrategy
используйте метод getByRequest()
класса ContextIdFactory
, чтобы создать идентификатор контекста на основе объекта запроса и передать его вызову resolve()
:
В приведенном выше примере метод resolve()
асинхронно возвращает экземпляр провайдера AuthService
с областью запроса (мы предположили, что AuthService
помечен как провайдер с областью запроса).
Расширение защитников(Extending guards)
В большинстве случаев достаточно использовать предоставленный класс AuthGuard
. Однако могут быть случаи использования, когда вы хотели бы просто расширить стандартную логику обработки ошибок или аутентификации. Для этого можно расширить встроенный класс и переопределить методы внутри подкласса.
Настройка Passport
Любые стандартные параметры настройки Passport могут быть переданы таким же образом, используя метод register(). Доступные варианты зависят от реализуемой стратегии. Например:
Вы также можете передать стратегии объект опций в их конструкторы, чтобы настроить их. Для локальной стратегии вы можете например:
Взгляните на официальный сайт Passport для получения имен свойств.
Именованные стратегии
При реализации стратегии вы можете указать ее имя, передав второй аргумент функции PassportStrategy. Если вы этого не сделаете, то каждая стратегия будет иметь имя по умолчанию (например, "jwt" для jwt-стратегии):
Теперь вы ссылаетесь на это с помощью декоратора, такого как @AuthGuard('my jwt')
.
GraphQL
Чтобы использовать AuthGuard
с GraphQL, расширьте встроенный класс AuthGuard
и переопределите метод getRequest()
.
Чтобы использовать приведенную выше конструкцию, обязательно передайте объект запроса (req
) как часть значения контекста в настройках модуля GraphQL:
Чтобы получить текущего аутентифицированного пользователя в вашем graphql резолвере, вы можете определить декоратор @CurrentUser()
:
Чтобы использовать вышеуказанный декоратор в вашем резолвере, обязательно включите его в качестве параметра вашего запроса или мутации:
Last updated