add authorization

This commit is contained in:
Sergey Krylov 2025-06-27 06:35:24 +03:00
parent b1a7097d02
commit a243549afb
18 changed files with 325 additions and 17 deletions

View File

@ -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",

View File

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

View File

@ -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 {

View File

@ -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<string>('ALLOWED_ORIGIN'),
credential: true,
credentials: true,
exposedHeaders: ['set-cookie'],
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<GqlContext>().req
const user = req.user
if (!user) {
throw new NotFoundException('Пользователь не найден')
}
return data ? user[data] : user
},
)

View File

@ -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<boolean> {
const ctx = GqlExecutionContext.create(context)
const { req } = ctx.getContext<GqlContext>()
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
}
}

View File

@ -0,0 +1,9 @@
import { User } from '@prisma/generated'
declare global {
namespace Express {
interface Request {
user?: User
}
}
}

View File

@ -0,0 +1,8 @@
import 'express-session'
declare module 'express-session' {
interface SessionData {
userId?: string
createdAt?: Date | string
}
}

View File

@ -0,0 +1,6 @@
import { Request, Response } from 'express'
export interface GqlContext {
req: Request
res: Response
}

View File

@ -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"