Пишем продвинутый планировщик с использованием React, Nest и NX. Часть 2: аутентификация

c8b638d3c875c6d721da44e73b636a32.jpeg

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

Аутентификация — процедура проверки подлинности, например:

  • проверка подлинности пользователя путём сравнения введённого им пароля (для указанного логина) с паролем, сохранённым в базе данных пользовательских логинов;

  • подтверждение подлинности электронного письма путём проверки цифровой подписи письма по открытому ключу отправителя;

  • проверка контрольной суммы файла на соответствие сумме, заявленной автором этого файла.

Passport — самая популярная библиотека для аутентификации в node.js с богатым набором стратегий, которую легко интегрировать в Nest. Мы реализуем комплексное решение сквозной аутентификации для сервера RESTful API. Её этапы:

  • Аутентификация пользователя, хеширование пароля с помощью библиотеки bcrypt и запись в базу данных.

  • Создание JWT.

  • Создание защищённого маршрута для проверки действительности JWT в запросе.

Коротко о модулях, контроллерах и провайдерах в Nest

Модули — это классы, аннотированные декоратором @Module(). Он предоставляет метаданные, которые Nest использует для организации структуры приложения.

Контроллеры отвечают за обработку входящих запросов и формирование ответов.

Провайдеры — это фундаментальная концепция Nest. Многие из базовых классов Nest могут рассматриваться как поставщики: сервисы, репозитории, фабрики, помощники и т. д.

Дополнительную информацию вы можете найти на официальном сайте Nest.

Установка необходимых пакетов

Нам потребуются:

  • библиотека passport;

  • модуль для работы с Nest @nestjs/passport;

  • пакет passport-jwt, который реализует стратегию JWT;

  • библиотека bcrypt, с помощью которой мы будем хешировать пароли перед их записью в базу данных;

  • и определения TypeScript-типов @types/passport-jwt @types/bcrypt.

Переходим в папку проекта part-1 и выполняем команды:

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

Запуск проекта и создание файлов

В прошлой части мы создали Docker-образ с MongoDB и настроили окружение для дальнейшей работы. Перед началом работы запустим проект следующими командами:

docker-compose up -d // Запускаем Docker
nx run-many --parallel --target=serve --projects=backend,frontend // Запускаем монорепозиторий с приложениями

После запуска приложения переходим в папку src в проекте backend и с помощью Nest CLI создаём, модуль, контроллер и сервис. Nest CLI — это инструмент интерфейса командной строки, который помогает инициализировать, разрабатывать и поддерживать ваши Nest-приложения.

nest g module auth // Создание модуля
nest g service auth --no-spec // Создание сервиса
nest g controller auth --no-spec // Создание контроллера

Опция --no-spec пропускает создание файлов с тестами (их написание выходит за рамки статьи).

Теперь у нас созданы файлы и в AuthModule автоматически импортированы AuthService и AuthController, а AuthModule импортирован в AppModule. Это всё благодаря работе Nest CLI. Ещё нам потребуется модуль и сервис для управления пользователями:

nest g module users // Создание модуля
nest g service users --no-spec // Создание сервиса

Я постараюсь отразить в комментариях цели и шаги выполнения кода.

Работа с пользователями

Создадим методы управления, проверки, добавления и хеширование паролей. После создания файлов нам нужно перейти в папку users, создать DTO для дальнейшей проверки информации и модель данных для MongoDB.

models/user.model.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { IUser } from '../interfaces/user.interface';

@Schema({ collection: 'users', timestamps: true }) // Указываем имя коллекции и свойство для автоматической записи времени в базу
export class UserModel extends Document implements IUser {
  @Prop({ required: true }) // Говорит, о том, что это обязательные данные
  username: string;
    
  @Prop({ required: true })
  password: string;
    
  @Prop({ required: true })
  email: string;
}
export const UserSchema = SchemaFactory.createForClass(UserModel); // Создаём схему.

Перед созданием DTO нам потребуется установить class-validator class-transformer:

npm i --save class-validator class-transformer

Описываем класс Dto, с помощью которого будут проверяться входящие запросы.

dto/user.dto.ts

import { IsNotEmpty, IsEmail } from 'class-validator';

export class UserDto {
  @IsNotEmpty() // Значение не может быть пустым
  id: string;
    
  @IsNotEmpty()
  username: string;
  
  @IsEmail() // Проверка на email
  email: string;
}

Модифицируем UserModule и UserService. Импортируем MongooseModule для дальнейшей работы с БД и создадим методы добавления, поиска и проверки пользователя.

users.module.ts

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UserModel, UserSchema } from './models/user.model';
import { UsersService } from './users.service';

@Module({
  imports: [
    MongooseModule.forFeature([ // Импортируем MongooseModule
      {
        name: UserModel.name, // Указываем имя модели, оно будет отображаться в базе данных
        schema: UserSchema, // Указываем схему для построения данных
      },
    ]),
  ],
  providers: [UsersService], // После работы с Nest CLI в providers автоматически импортирован UsersService
  exports: [UsersService], // Экспортируем UsersService, это позволяет использовать его за пределами модуля.
})

В сервисе заложена вся логика работы с БД и управления пользователями.

users.service.ts

import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { UserDto } from './dto/user.dto';
import { CreateUserDto } from './dto/user.create.dto';
import { LoginUserDto } from './dto/user-login.dto';
import { toUserDto } from '../shared/mapper';
import { InjectModel } from '@nestjs/mongoose';
import { UserModel } from './models/user.model';
import { Model } from 'mongoose';
import { genSalt, hash, compare } from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(
    @InjectModel(UserModel.name)
    private readonly userModel: Model// Внедряем модель БД в сервис для дальнейшего использования
  ) {}

  async findOne(options?: object): Promise {// Метод поиска одного пользователя
    const user = await this.userModel.findOne(options).exec();// Модель предоставляет нам методы для работы с БД
    return toUserDto(user); // Готовим данные к передаче пользователю
  }

  async findByLogin({ username, password }: LoginUserDto): Promise {// Метод проверки пользователя по имени и паролю
    const user = await this.userModel.findOne({ username }).exec();

    if (!user) { // Если пользователя нет, выводим ошибку 'User not found'
      throw new HttpException('User not found', HttpStatus.UNAUTHORIZED);
    }

    const areEqual = await compare(password, user.password); // С помощью библиотеки bcrypt вставляем оригинальный пароль и хеш; если они равны, то вернётся true

    if (!areEqual) {// Если пароли не равны, то выводим ошибку 'Invalid credentials'
      throw new HttpException('Invalid credentials', HttpStatus.UNAUTHORIZED);
    }

    return toUserDto(user); 
  }

  async findByPayload({ username }: any): Promise {// Поиск пользователя по имени
    return await this.findOne({ username });
  }

  async create(userDto: CreateUserDto): Promise {// Создание пользователя
    const { username, password, email } = userDto;

    const userInDb = await this.userModel.findOne({ username }).exec();
    if (userInDb) {// Если такой пользователь есть выводим ошибку
      throw new HttpException('User already exists', HttpStatus.BAD_REQUEST);
    }

    const salt = await genSalt(10); // С помощью библиотеки bycrypt создаём соль
    const hashPassword = await hash(password, salt); // bycrypt создаёт хеш пароля

    const user: UserModel = await new this.userModel({ // Создаём пользователя
      username,
      password: hashPassword,
      email,
    });
    
    await user.save(); // Сохраняем в БД

    return toUserDto(user);
  }

  private _sanitizeUser(user: UserModel) {
    delete user.password;
    return user;
  }
}

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

Ранее с помощью Nest CLI мы создали основные файлы для работы с аутентификацией, давайте ещё добавим подключение Passport и стратегию. Переходим в папку auth и создаём файл jwt.strategy.ts со следующей структурой:

jwt.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthService } from './auth.service';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { JwtPayload } from './interfaces/payload.interface';
import { UserDto } from '../users/dto/user.dto';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {// Наследуемся от PassportStrategy, добаляем методы и данные для работы стратегии
  constructor(private readonly authService: AuthService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_KEY + '',
    });
  }

  async validate(payload: JwtPayload): Promise { // Создаём метод валидации
    const user = await this.authService.validateUser(payload);
    if (!user) {
      throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
    }
    return user;
  }
}

Импортируем в модуль необходимые данные.

auth.module.ts

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { JwtStrategy } from './jwt.strategy';
import { ConfigService } from '@nestjs/config';

@Module({
  imports: [
    UsersModule,
    PassportModule.register({ // Импортируем PassportModule для дальнейшей работы с аутентификацией
      defaultStrategy: 'jwt',
      property: 'user',
      session: false,
    }),
    JwtModule.registerAsync({ // Добавляем JWT-модуль и предаем данные из .env
      useFactory: (config: ConfigService) => {
        return {
          secret: config.get('JWT_KEY'),
          signOptions: {
            expiresIn: config.get('EXPIRESIN'),
          },
        };
      },
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [PassportModule, JwtModule],
})
export class AuthModule {}

Важно передать UsersModule, иначе методы работы с пользователями не будут доступны в auth.

auth.service.ts

import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { RegistrationStatus } from './interfaces/regisration-status.interface';
import { LoginStatus } from './interfaces/login-status.interface';
import { LoginUserDto } from '../users/dto/user-login.dto';
import { JwtPayload } from './interfaces/payload.interface';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { CreateUserDto } from '../users/dto/user.create.dto';
import { UserDto } from '../users/dto/user.dto';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService
  ) {}

  async register(userDto: CreateUserDto): Promise { // Метод регистрации пользователя
    let status: RegistrationStatus = {
      success: true,
      message: 'user registered',
    };

    try {
      await this.usersService.create(userDto); // Передаём данные в usersService.create, если пользователя нет, то создастся новый, иначе ошибка 
    } catch (err) {
      status = {
        success: false,
        message: err,
      };
    }

    return status;
  }

  async login(loginUserDto: LoginUserDto): Promise { // Метод логина
    const user = await this.usersService.findByLogin(loginUserDto); // Ищем пользователя по соответствию
    const token = this._createToken(user); // Генерируем токен
    return { // Возвращаем данные
      username: user.username,
      ...token,
    };
  }

  async validateUser(payload: JwtPayload): Promise { // Проверка наличия пользователя
    const user = await this.usersService.findByPayload(payload);
    if (!user) {
      throw new HttpException('Invalid token', HttpStatus.UNAUTHORIZED);
    }
    return user;
  }

  private _createToken({ username }: UserDto): any { // Метод создания токена
    const expiresIn = process.env.EXPIRESIN + '';
    const user: JwtPayload = { username };
    const accessToken = this.jwtService.sign(user); 

    return {
      expiresIn,
      accessToken,
    };
  }
}

Подробнее остановимся на файле auth.controller.ts и поговорим о том, что здесь происходит. Мы будем использовать декоратор @Controller('auth'),  необходимый для определения базового контроллера. Укажем необязательный префикс 'auth';  использование префикса пути в декораторе позволяет группировать набор связанных маршрутов. Мы обсуждали ранее, что контроллер отвечает за обработку входящих запросов и формирование ответов. С помощью декораторов мы можем использовать различные методы запросов, например: GET, POST, PUT, DELETE.

Декоратор HTTP-метода @Post('register')  передаёт запрос методу register(), который обрабатывает конечную точку для HTTP-запросов. Конечная точка соответствует методу HTTP-запроса (в данном случае POST) и пути маршрута. Путь маршрута для обработчика определяется объединением (необязательного) префикса, объявленного для контроллера, и любого пути, указанного в декораторе метода. Поскольку мы объявили префикс для каждого маршрута @Controller('auth') и добавили декоратор @Post('register'), Nest будет сопоставлять запросы с маршрутом /auth/register.

В нашем примере, когда POST-запрос отправляется на конечную точку, Nest шлёт запрос пользовательскому методу register(). Обратите внимание, что имя метода, которое мы выбираем здесь, совершенно произвольное. Мы, очевидно, должны объявить метод для привязки маршрута, но Nest не придаёт никакого значения выбранному имени. Этот метод вернёт код состояния 201 и соответствующий ответ.

Немного расскажу о Pipes. Это класс, аннотированный декоратором @Injectable(), который реализует PipeTransform. С его помощью вы можете удобно трансформировать и проверять данные. Мы объявляем декоратор @UsePipes(new ValidationPipe()), который проверяет данные по схеме, описанной нами в CreateUserDto. Например, если мы не передали при регистрации email, нам вернётся такой ответ:

{
    "statusCode": 400,
    "message": [
        "email must be an email",
        "email should not be empty"
    ],
    "error": "Bad Request"
}

Это стандартный ответ, его можно настроить под свои требования.

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

Исходный код доступен по ссылке.

© Habrahabr.ru