add authorization
This commit is contained in:
parent
b1a7097d02
commit
a243549afb
@ -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",
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'],
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/src/module/auth/account/inputs/create-user.input.ts
Normal file
23
backend/src/module/auth/account/inputs/create-user.input.ts
Normal 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
|
||||
}
|
||||
16
backend/src/module/auth/session/inputs/login.input.ts
Normal file
16
backend/src/module/auth/session/inputs/login.input.ts
Normal 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
|
||||
}
|
||||
8
backend/src/module/auth/session/session.module.ts
Normal file
8
backend/src/module/auth/session/session.module.ts
Normal 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 {}
|
||||
23
backend/src/module/auth/session/session.resolver.ts
Normal file
23
backend/src/module/auth/session/session.resolver.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
65
backend/src/module/auth/session/session.service.ts
Normal file
65
backend/src/module/auth/session/session.service.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
6
backend/src/shared/decorators/auth.decorator.ts
Normal file
6
backend/src/shared/decorators/auth.decorator.ts
Normal 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))
|
||||
}
|
||||
20
backend/src/shared/decorators/authorized.decorator.ts
Normal file
20
backend/src/shared/decorators/authorized.decorator.ts
Normal 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
|
||||
},
|
||||
)
|
||||
33
backend/src/shared/guards/gql-auth.guard.ts
Normal file
33
backend/src/shared/guards/gql-auth.guard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
9
backend/src/shared/types/express-request.d.ts
vendored
Normal file
9
backend/src/shared/types/express-request.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
import { User } from '@prisma/generated'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: User
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/shared/types/express-session.d.ts
vendored
Normal file
8
backend/src/shared/types/express-session.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
import 'express-session'
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: string
|
||||
createdAt?: Date | string
|
||||
}
|
||||
}
|
||||
6
backend/src/shared/types/gql-context.types.ts
Normal file
6
backend/src/shared/types/gql-context.types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
export interface GqlContext {
|
||||
req: Request
|
||||
res: Response
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user