diff --git a/apps/api/package.json b/apps/api/package.json index fcc7182..b3a73f2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,8 +36,11 @@ "bcrypt": "^5.1.1", "cache-manager": "^5.2.4", "cache-manager-ioredis": "^2.1.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "ldap-authentication": "2.3.1", "mongoose": "^7.6.3", + "radash": "^11.0.0", "reflect-metadata": "^0.1.13", "rimraf": "^5.0.5", "rxjs": "^7.8.1", diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts index 47a278e..cc4d9bb 100644 --- a/apps/api/src/app.service.ts +++ b/apps/api/src/app.service.ts @@ -1,6 +1,7 @@ import type { DecodedToken } from './ldap/types/jwt'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { omit } from 'radash'; @Injectable() export class AppService { @@ -11,9 +12,8 @@ export class AppService { } public refreshToken(token: string) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { exp, iat, ...payload } = this.jwtService.decode(token) as DecodedToken; + const payload = this.jwtService.decode(token) as DecodedToken; - return this.jwtService.sign(payload); + return this.jwtService.sign(omit(payload, ['iat', 'exp'])); } } diff --git a/apps/api/src/dto/credentials.ts b/apps/api/src/dto/credentials.ts new file mode 100644 index 0000000..1dee2e6 --- /dev/null +++ b/apps/api/src/dto/credentials.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class Credentials { + @IsString() + @IsNotEmpty() + readonly login: string; + + @IsString() + @IsNotEmpty() + readonly password: string; +} diff --git a/apps/api/src/ldap/dto/credentials.ts b/apps/api/src/ldap/dto/credentials.ts deleted file mode 100644 index 0c642fa..0000000 --- a/apps/api/src/ldap/dto/credentials.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class Credentials { - readonly login: string; - readonly password: string; -} diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 77e6d02..15ef03c 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ /* eslint-disable class-methods-use-this */ /* eslint-disable import/no-extraneous-dependencies */ -import { Credentials } from './dto/credentials'; +import { Credentials } from '../dto/credentials'; import { LdapService } from './ldap.service'; import type { CookieSerializeOptions } from '@fastify/cookie'; import { Body, Controller, Get, HttpException, HttpStatus, Post, Req, Res } from '@nestjs/common'; @@ -26,10 +26,8 @@ export class LdapController { @Post('/signin') async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) { - const { login, password } = credentials; - try { - const token = await this.ldapService.login(login, password); + const token = await this.ldapService.login(credentials); return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send(); } catch { diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 1f78444..15d3fc1 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Cache } from 'cache-manager'; import { env } from 'src/config/env'; +import type { Credentials } from 'src/dto/credentials'; import * as ldap from 'src/utils/ldap'; @Injectable() @@ -13,7 +14,7 @@ export class LdapService { private readonly jwtService: JwtService ) {} - public async login(login: string, password: string) { + public async login({ login, password }: Credentials) { const user = await ldap.authenticate(login, password); const { username } = user; diff --git a/apps/api/src/ldap/types/jwt.ts b/apps/api/src/ldap/types/jwt.ts index 40a4b07..1e58a0f 100644 --- a/apps/api/src/ldap/types/jwt.ts +++ b/apps/api/src/ldap/types/jwt.ts @@ -1,5 +1,5 @@ export type TokenPayload = { - domain: string; + [key: string]: unknown; username: string; }; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b70de73..ff55960 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,7 +1,9 @@ +/* eslint-disable import/no-duplicates */ /* eslint-disable unicorn/prefer-top-level-await */ import { AppModule } from './app.module'; import { env } from './config/env'; import { fastifyCookie } from '@fastify/cookie'; +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter } from '@nestjs/platform-fastify'; @@ -18,6 +20,8 @@ async function bootstrap() { secret: env.API_SECRET, }); + app.useGlobalPipes(new ValidationPipe({ stopAtFirstError: true })); + await app.listen(env.API_PORT, '0.0.0.0'); } diff --git a/apps/api/src/users/dto/create-user.dto.ts b/apps/api/src/users/dto/create-user.dto.ts index 2aeedc6..74897d3 100644 --- a/apps/api/src/users/dto/create-user.dto.ts +++ b/apps/api/src/users/dto/create-user.dto.ts @@ -1,5 +1,14 @@ +import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; + export class CreateUserDto { + @IsString() + @IsNotEmpty() readonly username: string; + + @IsString() + @MinLength(10) + @IsOptional() readonly password: string; + readonly [key: string]: unknown; } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index ed86b12..52584ca 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -3,30 +3,82 @@ /* eslint-disable import/no-extraneous-dependencies */ import { CreateUserDto } from './dto/create-user.dto'; import { UsersService } from './users.service'; -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; -import { User } from 'src/schemas/user.schema'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + Post, + Req, + Res, +} from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { cookieOptions } from 'src/config/cookie'; +import { env } from 'src/config/env'; +import { Credentials } from 'src/dto/credentials'; -@Controller() +@Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} - @Post('/users/create-user') - async create(@Body() createUserDto: CreateUserDto) { - return this.usersService.create(createUserDto); + private clearCookies(req, reply) { + if (req.cookies) { + Object.keys(req.cookies).forEach((cookieName) => { + reply.clearCookie(cookieName, { + path: '/', + }); + }); + } } - @Get('/users') + @Post('/create') + async create(@Body() createUserDto: CreateUserDto, @Res() reply: FastifyReply) { + try { + const createdUser = await this.usersService.create(createUserDto); + + return reply.send(createdUser); + } catch (error) { + throw new HttpException(error, HttpStatus.BAD_REQUEST); + } + } + + @Get() async findAll() { return this.usersService.findAll(); } - @Delete('/users/delete/:username') - async delete(@Param('username') username: string) { + @Delete('/delete/:login') + async delete(@Param('login') username: CreateUserDto['username']) { return this.usersService.delete(username); } - @Post('/users/check') - async check(@Body() user: User) { - return this.usersService.check(user); + @Post('/signin') + async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) { + try { + const token = await this.usersService.login(credentials); + + return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send(); + } catch { + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + } + + @Get('/logout') + async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) { + this.clearCookies(req, reply); + + return reply.status(302).redirect('/login'); + } + + @Get('/get-user') + async getUser(@Req() req: FastifyRequest, @Res() reply: FastifyReply) { + const token = req.cookies[env.COOKIE_TOKEN_NAME]; + + const user = await this.usersService.getUser(token); + + return reply.send(user); } } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 63ce0b7..d932a9f 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,9 +1,12 @@ import type { CreateUserDto } from './dto/create-user.dto'; -import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { InjectModel } from '@nestjs/mongoose'; import * as bcrypt from 'bcrypt'; import { Model } from 'mongoose'; +import { omit } from 'radash'; +import type { Credentials } from 'src/dto/credentials'; +import type { TokenPayload } from 'src/ldap/types/jwt'; import { User } from 'src/schemas/user.schema'; import { generatePassword } from 'src/utils/password'; @@ -32,20 +35,29 @@ export class UsersService { return this.userModel.findOneAndDelete({ username }).exec(); } - public async check({ username, password }: User) { + public async login({ login, password }: Credentials) { try { - const user = await this.userModel.findOne({ username }); + const user = await this.userModel.findOne({ username: login }); if (!user) { - throw new NotFoundException('User not found'); + throw new UnauthorizedException('User not found'); } const passwordMatch = await bcrypt.compare(password, user.password); if (!passwordMatch) { throw new UnauthorizedException('Invalid login credentials'); } - return passwordMatch; + const payload: TokenPayload = { + username: login, + ...omit(user.toJSON(), ['password']), + }; + + return this.jwtService.sign(payload); } catch (error) { throw new UnauthorizedException(error); } } + + public async getUser(token: string) { + return this.jwtService.decode(token); + } } diff --git a/yarn.lock b/yarn.lock index 1ba1d6d..c413e0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1578,6 +1578,11 @@ dependencies: "@types/superagent" "*" +"@types/validator@^13.7.10": + version "13.11.5" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.11.5.tgz#1911964fd5556b08d3479d1ded977c06f89a44a7" + integrity sha512-xW4qsT4UIYILu+7ZrBnfQdBYniZrMLYYK3wN9M/NdeIHgBN5pZI2/8Q7UfdWIcr5RLJv/OGENsx91JIpUUoC7Q== + "@types/webidl-conversions@*": version "7.0.2" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.2.tgz#d703e2bf61d8b77a7669adcd8fdf98108155d594" @@ -2668,6 +2673,20 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +class-transformer@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + +class-validator@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.0.tgz#40ed0ecf3c83b2a8a6a320f4edb607be0f0df159" + integrity sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A== + dependencies: + "@types/validator" "^13.7.10" + libphonenumber-js "^1.10.14" + validator "^13.7.0" + clean-regexp@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clean-regexp/-/clean-regexp-1.0.0.tgz#8df7c7aae51fd36874e8f8d05b9180bc11a3fed7" @@ -5774,6 +5793,11 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libphonenumber-js@^1.10.14: + version "1.10.49" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.49.tgz#c871661c62452348d228c96425f75ddf7e10f05a" + integrity sha512-gvLtyC3tIuqfPzjvYLH9BmVdqzGDiSi4VjtWe2fAgSdBf0yt8yPmbNnRIHNbR5IdtVkm0ayGuzwQKTWmU0hdjQ== + light-my-request@5.11.0, light-my-request@^5.9.1: version "5.11.0" resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-5.11.0.tgz#90e446c303b3a47b59df38406d5f5c2cf224f2d1" @@ -6829,6 +6853,11 @@ quick-format-unescaped@^4.0.3: resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== +radash@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/radash/-/radash-11.0.0.tgz#925698e94b554336fc159c253ac33a9a49881835" + integrity sha512-CRWxTFTDff0IELGJ/zz58yY4BDgyI14qSM5OLNKbCItJrff7m7dXbVF0kWYVCXQtPb3SXIVhXvAImH6eT7VLSg== + rambda@^7.4.0: version "7.5.0" resolved "https://registry.yarnpkg.com/rambda/-/rambda-7.5.0.tgz#1865044c59bc0b16f63026c6e5a97e4b1bbe98fe" @@ -8231,6 +8260,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validator@^13.7.0: + version "13.11.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b" + integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ== + value-or-promise@^1.0.11, value-or-promise@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.12.tgz#0e5abfeec70148c78460a849f6b003ea7986f15c"