diff --git a/backend/package.json b/backend/package.json index 6216f9c..4c7813f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,9 +31,10 @@ "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", "@prisma/client": "^6.10.1", + "argon2": "^0.43.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", - "connect-redis": "^9.0.0", + "connect-redis": "^7.1.1", "cookie-parser": "^1.4.7", "express-session": "^1.18.1", "graphql": "^16.11.0", diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index 41e4618..21e873e 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -1,5 +1,6 @@ import { getGraphQLConfig } from '@/src/core/config/graphql.config' import { AccountModule } from '@/src/module/auth/account/account.module' +import { SessionModule } from '@/src/module/auth/session/session.module' import { IS_DEV } from '@/src/shared/util/is-dev.util' import { ApolloDriver } from '@nestjs/apollo' import { Module } from '@nestjs/common' @@ -23,6 +24,7 @@ import { RedisModule } from './redis/redis.module' PrismaModule, RedisModule, AccountModule, + SessionModule, ], }) export class CoreModule {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index 135e108..3e1017c 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -2,13 +2,30 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +input CreateUserInput { + email: String! + name: String! + password: String! +} + """ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. """ scalar DateTime +input LoginInput { + login: String! + password: String! +} + +type Mutation { + createUser(data: CreateUserInput!): UserModel! + loginUser(data: LoginInput!): UserModel! + logoutUser: Boolean! +} + type Query { - findAllUsers: [UserModel!]! + findProfile: UserModel! } type UserModel { diff --git a/backend/src/main.ts b/backend/src/main.ts index be2717d..9c5c3bb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,7 +4,7 @@ import { parseBoolean } from '@/src/shared/util/parse-boolean.util' import { ValidationPipe } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { NestFactory } from '@nestjs/core' -import { RedisStore } from 'connect-redis' +import RedisStore from 'connect-redis' import * as cookieParser from 'cookie-parser' import { CoreModule } from '@/src/core/core.module' import * as session from 'express-session' @@ -41,7 +41,7 @@ async function bootstrap() { app.enableCors({ origin: config.getOrThrow('ALLOWED_ORIGIN'), - credential: true, + credentials: true, exposedHeaders: ['set-cookie'], }) diff --git a/backend/src/module/auth/account/account.resolver.ts b/backend/src/module/auth/account/account.resolver.ts index a56d570..5becf63 100644 --- a/backend/src/module/auth/account/account.resolver.ts +++ b/backend/src/module/auth/account/account.resolver.ts @@ -1,4 +1,7 @@ -import { Query, Resolver } from '@nestjs/graphql' +import { Authorization } from '@/src/shared/decorators/auth.decorator' +import { Authorized } from '@/src/shared/decorators/authorized.decorator' +import { CreateUserInput } from './inputs/create-user.input' +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' import { AccountService } from './account.service' import { UserModel } from './models/user.model' @@ -6,8 +9,14 @@ import { UserModel } from './models/user.model' export class AccountResolver { constructor(private readonly accountService: AccountService) {} - @Query(() => [UserModel], { name: 'findAllUsers' }) - public async findAll() { - return await this.accountService.findAll() + @Query(() => UserModel, { name: 'findProfile' }) + @Authorization() + public me(@Authorized('id') id: UserModel['id']) { + return this.accountService.me(id) + } + + @Mutation(() => UserModel, { name: 'createUser' }) + public async create(@Args('data') input: CreateUserInput) { + return this.accountService.create(input) } } diff --git a/backend/src/module/auth/account/account.service.ts b/backend/src/module/auth/account/account.service.ts index e2fc91d..b076a92 100644 --- a/backend/src/module/auth/account/account.service.ts +++ b/backend/src/module/auth/account/account.service.ts @@ -1,5 +1,8 @@ +import { User } from '@/prisma/generated' import { PrismaService } from '@/src/core/prisma/prisma.service' -import { Injectable } from '@nestjs/common' +import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input' +import { ConflictException, Injectable } from '@nestjs/common' +import { hash } from 'argon2' @Injectable() export class AccountService { @@ -8,8 +11,43 @@ export class AccountService { ) { } - public async findAll() { - const users = await this.prismaService.user.findMany({}) - return users + public async me(id: User['id']) { + return this.prismaService.user.findUnique({ + where: { + id, + }, + }) + } + + public async create(input: CreateUserInput) { + const { email, name, password } = input + const isUserNameExists = await this.prismaService.user.findUnique({ + where: { + name, + }, + }) + + if (isUserNameExists) { + throw new ConflictException('Пользователь с таким именем уже существует') + } + + const isUserEmailExists = await this.prismaService.user.findUnique({ + where: { + email, + }, + }) + + if (isUserEmailExists) { + throw new ConflictException('Пользователь с такоей почтой уже существует') + } + + return this.prismaService.user.create({ + data: { + name, + email, + password: await hash(password), + displayName: name, + }, + }) } } diff --git a/backend/src/module/auth/account/inputs/create-user.input.ts b/backend/src/module/auth/account/inputs/create-user.input.ts new file mode 100644 index 0000000..890609f --- /dev/null +++ b/backend/src/module/auth/account/inputs/create-user.input.ts @@ -0,0 +1,23 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validator' + +@InputType() +export class CreateUserInput { + @Field() + @IsString() + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/) + name: string + + @Field() + @IsString() + @IsNotEmpty() + @IsEmail() + email: string + + @Field() + @IsString() + @IsNotEmpty() + @MinLength(8) + password: string +} diff --git a/backend/src/module/auth/session/inputs/login.input.ts b/backend/src/module/auth/session/inputs/login.input.ts new file mode 100644 index 0000000..46db919 --- /dev/null +++ b/backend/src/module/auth/session/inputs/login.input.ts @@ -0,0 +1,16 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsNotEmpty, IsString, MinLength } from 'class-validator' + +@InputType() +export class LoginInput { + @Field() + @IsString() + @IsNotEmpty() + login: string + + @Field() + @IsString() + @IsNotEmpty() + @MinLength(8) + password: string +} diff --git a/backend/src/module/auth/session/session.module.ts b/backend/src/module/auth/session/session.module.ts new file mode 100644 index 0000000..dd2fc67 --- /dev/null +++ b/backend/src/module/auth/session/session.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SessionService } from './session.service'; +import { SessionResolver } from './session.resolver'; + +@Module({ + providers: [SessionResolver, SessionService], +}) +export class SessionModule {} diff --git a/backend/src/module/auth/session/session.resolver.ts b/backend/src/module/auth/session/session.resolver.ts new file mode 100644 index 0000000..2b79abb --- /dev/null +++ b/backend/src/module/auth/session/session.resolver.ts @@ -0,0 +1,23 @@ +import { UserModel } from '@/src/module/auth/account/models/user.model' +import { LoginInput } from '@/src/module/auth/session/inputs/login.input' +import { GqlContext } from '@/src/shared/types/gql-context.types' +import { Args, Context, Mutation, Resolver } from '@nestjs/graphql' +import { SessionService } from './session.service' + +@Resolver('Session') +export class SessionResolver { + constructor(private readonly sessionService: SessionService) {} + + @Mutation(() => UserModel, { name: 'loginUser' }) + public async login( + @Context() { req }: GqlContext, + @Args('data') input: LoginInput, + ) { + return this.sessionService.login(req, input) + } + + @Mutation(() => Boolean, { name: 'logoutUser' }) + public async logout(@Context() { req }: GqlContext) { + return this.sessionService.logout(req) + } +} diff --git a/backend/src/module/auth/session/session.service.ts b/backend/src/module/auth/session/session.service.ts new file mode 100644 index 0000000..25d0e72 --- /dev/null +++ b/backend/src/module/auth/session/session.service.ts @@ -0,0 +1,65 @@ +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { LoginInput } from '@/src/module/auth/session/inputs/login.input' +import { + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { verify } from 'argon2' +import { Request } from 'express' + +@Injectable() +export class SessionService { + constructor( + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + ) {} + + public async login(req: Request, input: LoginInput) { + const { login, password } = input + + const user = await this.prismaService.user.findFirst({ where: { + OR: [ + { name: { equals: login } }, + { email: { equals: login } }, + ], + } }) + + if (!user) { + throw new NotFoundException(`Пользователь не найден`) + } + + const isValidPassword = await verify(user.password, password) + if (!isValidPassword) { + throw new UnauthorizedException('Логин или пароль неверный') + } + + return new Promise((resolve, reject) => { + req.session.userId = user.id + req.session.createdAt = new Date() + + req.session.save((error) => { + if (error) { + reject(new InternalServerErrorException('Не удалось сохранить сессию')) + } + + resolve(user) + }) + }) + } + + public async logout(req: Request) { + return new Promise((resolve, reject) => { + req.session.destroy((error) => { + if (error) { + reject(new InternalServerErrorException('Не удалось завершить сессию')) + } + + req.res?.clearCookie(this.configService.getOrThrow('SESSION_NAME')) + resolve(true) + }) + }) + } +} diff --git a/backend/src/shared/decorators/auth.decorator.ts b/backend/src/shared/decorators/auth.decorator.ts new file mode 100644 index 0000000..d6b7d99 --- /dev/null +++ b/backend/src/shared/decorators/auth.decorator.ts @@ -0,0 +1,6 @@ +import { GqlAuthGuard } from '@/src/shared/guards/gql-auth.guard' +import { applyDecorators, UseGuards } from '@nestjs/common' + +export function Authorization() { + return applyDecorators(UseGuards(GqlAuthGuard)) +} diff --git a/backend/src/shared/decorators/authorized.decorator.ts b/backend/src/shared/decorators/authorized.decorator.ts new file mode 100644 index 0000000..9ea244e --- /dev/null +++ b/backend/src/shared/decorators/authorized.decorator.ts @@ -0,0 +1,20 @@ +import { GqlContext } from '@/src/shared/types/gql-context.types' +import { createParamDecorator, NotFoundException } from '@nestjs/common' +import { GqlExecutionContext } from '@nestjs/graphql' +import { User } from '@prisma/generated' +import { Request } from 'express' + +export const Authorized = createParamDecorator( + (data: keyof User, ctx) => { + const req: Request = ctx.getType() === 'http' + ? ctx.switchToHttp().getRequest() + : GqlExecutionContext.create(ctx).getContext().req + + const user = req.user + if (!user) { + throw new NotFoundException('Пользователь не найден') + } + + return data ? user[data] : user + }, +) diff --git a/backend/src/shared/guards/gql-auth.guard.ts b/backend/src/shared/guards/gql-auth.guard.ts new file mode 100644 index 0000000..9060a1d --- /dev/null +++ b/backend/src/shared/guards/gql-auth.guard.ts @@ -0,0 +1,33 @@ +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { GqlContext } from '@/src/shared/types/gql-context.types' +import { CanActivate, ExecutionContext, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common' +import { GqlExecutionContext } from '@nestjs/graphql' + +@Injectable() +export class GqlAuthGuard implements CanActivate { + constructor( + private readonly prismaService: PrismaService, + ) { + } + + async canActivate(context: ExecutionContext): Promise { + const ctx = GqlExecutionContext.create(context) + const { req } = ctx.getContext() + + if (typeof req.session.userId === 'undefined') { + throw new UnauthorizedException('Пользователь не авторизован') + } + + const user = await this.prismaService.user.findUnique({ + where: { + id: req.session.userId, + }, + }) + if (!user) { + throw new NotFoundException('Пользователь не найден') + } + + req.user = user + return true + } +} diff --git a/backend/src/shared/types/express-request.d.ts b/backend/src/shared/types/express-request.d.ts new file mode 100644 index 0000000..5f6a38c --- /dev/null +++ b/backend/src/shared/types/express-request.d.ts @@ -0,0 +1,9 @@ +import { User } from '@prisma/generated' + +declare global { + namespace Express { + interface Request { + user?: User + } + } +} diff --git a/backend/src/shared/types/express-session.d.ts b/backend/src/shared/types/express-session.d.ts new file mode 100644 index 0000000..6bfa84d --- /dev/null +++ b/backend/src/shared/types/express-session.d.ts @@ -0,0 +1,8 @@ +import 'express-session' + +declare module 'express-session' { + interface SessionData { + userId?: string + createdAt?: Date | string + } +} diff --git a/backend/src/shared/types/gql-context.types.ts b/backend/src/shared/types/gql-context.types.ts new file mode 100644 index 0000000..e538b13 --- /dev/null +++ b/backend/src/shared/types/gql-context.types.ts @@ -0,0 +1,6 @@ +import { Request, Response } from 'express' + +export interface GqlContext { + req: Request + res: Response +} diff --git a/backend/yarn.lock b/backend/yarn.lock index 6ab5ee5..27bcfab 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1362,6 +1362,11 @@ dependencies: "@noble/hashes" "^1.1.5" +"@phc/format@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@phc/format/-/format-1.0.0.tgz#b5627003b3216dc4362125b13f48a4daa76680e4" + integrity sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ== + "@prisma/client@^6.10.1": version "6.10.1" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-6.10.1.tgz#f5cf8731727ea833b53524f300458d0adbb5c58f" @@ -2398,6 +2403,15 @@ arg@^4.1.0: resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== +argon2@^0.43.0: + version "0.43.0" + resolved "https://registry.yarnpkg.com/argon2/-/argon2-0.43.0.tgz#4a5d8943506923ce7450f4e9069e03e134c7473e" + integrity sha512-u/HKLcbWShVDhkfwI4hWyiUf3qyX8QhTfaIv2cWE18uqhXCmR5hb6Ed7oqYi2KCQegeAnRhiFzbjzm7i5yl1GA== + dependencies: + "@phc/format" "^1.0.0" + node-addon-api "^8.3.1" + node-gyp-build "^4.8.4" + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -2898,10 +2912,10 @@ concat-stream@^2.0.0: readable-stream "^3.0.2" typedarray "^0.0.6" -connect-redis@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/connect-redis/-/connect-redis-9.0.0.tgz#d3c68451a466d007c4be9fae8b652e0f60d9b769" - integrity sha512-QwzyvUePTMvEzG1hy45gZYw3X3YHrjmEdSkayURlcZft7hqadQ3X39wYkmCqblK2rGlw+XItELYt6GnyG6DEIQ== +connect-redis@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/connect-redis/-/connect-redis-7.1.1.tgz#b78f91eb6d7509ae9e819bb362b94ba459072a1d" + integrity sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ== consola@^3.2.3: version "3.4.2" @@ -4098,7 +4112,7 @@ inspect-with-kind@^1.0.5: dependencies: kind-of "^6.0.2" -ioredis@^5.6.1: +ioredis@5.6.1: version "5.6.1" resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.6.1.tgz#1ed7dc9131081e77342503425afceaf7357ae599" integrity sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA== @@ -5085,6 +5099,11 @@ node-abort-controller@^3.0.1, node-abort-controller@^3.1.1: resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== +node-addon-api@^8.3.1: + version "8.4.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.4.0.tgz#8cbc68ee1c216368921a8f63038a23a39cd8ba44" + integrity sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg== + node-emoji@1.11.0: version "1.11.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" @@ -5099,6 +5118,11 @@ node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-gyp-build@^4.8.4: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"