diff --git a/.env.example b/.env.example index e1a754a..be1e1b2 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ DATABASE_PORT=5432 SERVER_PORT=3000 REDIS_PASSWORD=123 + +JWT_SECRET=secret \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0ffaac4..120c67d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ # Env files **/.env **/.env.* -!**/.env.example \ No newline at end of file +!**/.env.example +node_modules \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 5368c51..9c24287 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,10 @@ { - "singleQuote": true, - "trailingComma": "all", - "semi": true, - "tabWidth": 4, - "printWidth": 150, - "bracketSpacing": true, - "endOfLine": "lf" + "arrowParens": "always", + "printWidth": 100, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 2, + "useTabs": false, + "semi": true, + "bracketSpacing": true } diff --git a/.vscode/settings.json b/.vscode/settings.json index c4f402b..2b335b6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "**/node_modules": true }, "editor.formatOnSave": true, + "editor.tabSize": 2, "editor.defaultFormatter": "esbenp.prettier-vscode", "[dockercompose]": { "editor.insertSpaces": true, diff --git a/backend/bun.lockb b/backend/bun.lockb new file mode 100755 index 0000000..79b44cf Binary files /dev/null and b/backend/bun.lockb differ diff --git a/backend/config/index.ts b/backend/config/index.ts index ef71389..56249a2 100644 --- a/backend/config/index.ts +++ b/backend/config/index.ts @@ -1,27 +1,30 @@ -import { config as configInit } from 'dotenv'; +import { config as configInit } from "dotenv"; -configInit({ path: '../.env' }); +configInit({ path: "../.env" }); export const config = { - database: { - type: 'postgres', - host: process.env.DATABASE_HOST || 'localhost', - port: +process.env.DATABASE_PORT || 5432, - username: process.env.DATABASE_USERNAME || 'postgres', - password: process.env.DATABASE_PASSWORD || '', - database: process.env.DATABASE_DB || 'bot_db', - synchronize: true, - logging: false, - autoLoadEntities: true, - }, - redis: { - redis_host: process.env.REDIS_HOST || 'localhost', - redis_port: +process.env.REDIS_PORT || 6379, - redis_password: process.env.REDIS_PASSWORD || '', - redis_database: +process.env.REDIS_DB || 0, - }, - server: { - port: +process.env.SERVER_PORT || 8080, - access_token: process.env.ACCESS_TOKEN || '', - }, + database: { + type: "postgres", + host: process.env.DATABASE_HOST || "localhost", + port: +process.env.DATABASE_PORT || 5432, + username: process.env.DATABASE_USERNAME || "postgres", + password: process.env.DATABASE_PASSWORD || "", + database: process.env.DATABASE_DB || "bot_db", + synchronize: true, + logging: false, + autoLoadEntities: true, + }, + redis: { + redis_host: process.env.REDIS_HOST || "localhost", + redis_port: +process.env.REDIS_PORT || 6379, + redis_password: process.env.REDIS_PASSWORD || "", + redis_database: +process.env.REDIS_DB || 0, + }, + server: { + port: +process.env.SERVER_PORT || 8080, + access_token: process.env.ACCESS_TOKEN || "", + }, + auth: { + jwt_secret: process.env.JWT_SECRET || "secret", + }, }; diff --git a/backend/libs/database/web_user.entity.ts b/backend/libs/database/web_user.entity.ts new file mode 100644 index 0000000..24bf71f --- /dev/null +++ b/backend/libs/database/web_user.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity() +export class WebUser { + @PrimaryGeneratedColumn("uuid") + public uuid: string; + + @Column({ unique: true }) + public login: string; + + @Column({ nullable: false }) + public password: string; + + @Column({ nullable: true }) + public telegram_id?: string; +} diff --git a/backend/libs/libs.module.ts b/backend/libs/libs.module.ts index 93885a3..bae0cfd 100644 --- a/backend/libs/libs.module.ts +++ b/backend/libs/libs.module.ts @@ -1,14 +1,17 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Admin } from './database/admin.entity'; -import { Image } from './database/image.entity'; -import { Payment } from './database/payment.entity'; -import { Post } from './database/post.entity'; -import { ProxyUser } from './database/proxy_user.entity'; -import { BotSettings } from './database/settings.entity'; -import { User } from './database/user.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Admin } from "./database/admin.entity"; +import { Image } from "./database/image.entity"; +import { Payment } from "./database/payment.entity"; +import { Post } from "./database/post.entity"; +import { ProxyUser } from "./database/proxy_user.entity"; +import { BotSettings } from "./database/settings.entity"; +import { User } from "./database/user.entity"; +import { WebUser } from "./database/web_user.entity"; @Module({ - imports: [TypeOrmModule.forFeature([User, Admin, Post, Image, Payment, ProxyUser, BotSettings])], - exports: [TypeOrmModule], + imports: [ + TypeOrmModule.forFeature([User, Admin, Post, Image, Payment, ProxyUser, BotSettings, WebUser]), + ], + exports: [TypeOrmModule], }) export class LibsModule {} diff --git a/backend/package.json b/backend/package.json index a853e00..a4f79cc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,81 +1,83 @@ { - "name": "backend", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/src/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/src/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/cache-manager": "2.2.1", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.1.16", + "@nestjs/typeorm": "^10.0.1", + "bcrypt": "^5.1.1", + "cache-manager": "^5.4.0", + "cache-manager-redis-store": "^3.0.1", + "dotenv": "^16.3.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "pg": "^8.11.3", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "typeorm": "^0.3.17" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/passport-http-bearer": "^1.0.41", + "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3", + "@types/bun": "latest" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" }, - "dependencies": { - "@nestjs/cache-manager": "2.2.1", - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/swagger": "^7.1.16", - "@nestjs/typeorm": "^10.0.1", - "cache-manager": "^5.4.0", - "cache-manager-redis-store": "^3.0.1", - "dotenv": "^16.3.1", - "passport": "^0.7.0", - "passport-http-bearer": "^1.0.1", - "pg": "^8.11.3", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "typeorm": "^0.3.17" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.2", - "@types/node": "^20.3.1", - "@types/passport-http-bearer": "^1.0.41", - "@types/supertest": "^2.0.12", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "eslint": "^8.42.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.5.0", - "prettier": "^3.0.0", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - } + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } } diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..34c3b89 --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Request, + UseGuards, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtGuard } from "./auth.guard"; +import { AuthService } from "./auth.service"; +import { LoginDto } from "./dto/login.dto"; + +@ApiTags("auth") +@Controller("auth") +export class AuthController { + constructor(private authService: AuthService) {} + + @ApiOperation({ + description: "Login into system", + }) + @HttpCode(HttpStatus.OK) + @Post("login") + signIn(@Body() signInDto: LoginDto) { + return this.authService.signIn(signInDto.username, signInDto.password); + } + + @ApiOperation({ + description: "Register into system", + }) + @HttpCode(HttpStatus.CREATED) + @Post("register") + register(@Body() signInDto: LoginDto) { + return this.authService.register(signInDto.username, signInDto.password); + } + + @ApiOperation({ + description: "Get user profile", + }) + @UseGuards(JwtGuard) + @Get("profile") + @ApiBearerAuth() + getProfile(@Request() req) { + return req.user; + } +} diff --git a/backend/src/modules/auth/auth.guard.ts b/backend/src/modules/auth/auth.guard.ts index fdd646f..cb551c9 100644 --- a/backend/src/modules/auth/auth.guard.ts +++ b/backend/src/modules/auth/auth.guard.ts @@ -1,36 +1,31 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { AuthService } from './auth.service'; - +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; @Injectable() -export class AuthGuard implements CanActivate { - constructor( - private reflector: Reflector, - private readonly authService: AuthService, - ) {} +export class JwtGuard extends AuthGuard("jwt") {} +// export class AuthGuard implements CanActivate { +// constructor(private jwtService: JwtService) {} - canActivate(context: ExecutionContext) { - const request = context.switchToHttp().getRequest(); - const allowUnauthorizedRequest = this.reflector.get('allowUnauthorizedRequest', context.getHandler()); +// async canActivate(context: ExecutionContext): Promise { +// const request = context.switchToHttp().getRequest(); +// const token = this.extractTokenFromHeader(request); +// if (!token) { +// throw new UnauthorizedException(); +// } +// try { +// const payload = await this.jwtService.verifyAsync(token, { +// secret: config.auth.jwt_secret, +// }); - let token = this.extractTokenFromHeader(request.headers); +// request["user"] = payload; +// } catch { +// throw new UnauthorizedException(); +// } - if (!token) { - token = request.query.access_token || request.body.access_token; - } - if (allowUnauthorizedRequest || this.authService.authUserByToken(token)) return true; - throw new UnauthorizedException('Unathorized!'); - } +// return true; +// } - private extractTokenFromHeader(headers: any): string | null { - if (headers && headers.authorization) { - const authHeader = headers.authorization as string; - const headerParts = authHeader.split(' '); - - if (headerParts.length === 2 && headerParts[0].toLowerCase() === 'bearer') { - return headerParts[1]; - } - } - return null; - } -} +// private extractTokenFromHeader(request: Request): string | undefined { +// const [type, token] = request.headers.authorization.split(" ") ?? []; +// return type === "Bearer" ? token : undefined; +// } +// } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 7d044f2..57b156f 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -1,11 +1,24 @@ -import { Module } from '@nestjs/common'; -import { PassportModule } from '@nestjs/passport'; -import { AuthService } from './auth.service'; -import { HttpBearerStrategy } from './http-bearer.strategy'; +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { LibsModule } from "libs/libs.module"; +import { config } from "../../../config"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { JwtStrategy } from "./strategy/jwt.strategy"; @Module({ - imports: [PassportModule.register({ defaultStrategy: 'bearer' })], - providers: [HttpBearerStrategy, AuthService], - exports: [HttpBearerStrategy, AuthService], + imports: [ + LibsModule, + PassportModule, + JwtModule.register({ + secret: config.auth.jwt_secret, + global: true, + signOptions: { expiresIn: 86000 }, + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index e8409f4..2a35d64 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,10 +1,51 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { config } from 'config'; +import { Injectable, Logger, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { InjectRepository } from "@nestjs/typeorm"; +import * as bcrypt from "bcrypt"; +import { WebUser } from "libs/database/web_user.entity"; +import { Repository } from "typeorm"; @Injectable() export class AuthService { - private readonly logger: Logger = new Logger(AuthService.name); - authUserByToken(token: string) { - return token === config.server.access_token; + private readonly logger: Logger = new Logger(AuthService.name); + constructor( + @InjectRepository(WebUser) private userRepository: Repository, + private jwtService: JwtService, + ) {} + + async signIn(username: string, pass: string): Promise<{ access_token: string }> { + const user = await this.userRepository.findOne({ where: { login: username } }); + if (!user) throw new UnauthorizedException("User not found"); + const isMatch = await bcrypt.compare(pass, user.password); + if (!isMatch) throw new UnauthorizedException("Wrong password"); + const payload = { sub: user.uuid, username: user.login }; + this.logger.log(`User ${user.login} logged in`); + return { access_token: await this.jwtService.signAsync(payload) }; + } + + async validateUserJwt(uuid: string): Promise { + const user = await this.userRepository.findOneBy({ uuid: uuid }); + if (!user) { + throw new UnauthorizedException("User not found"); } + return { + login: user.login, + telegram_id: user.telegram_id, + }; + } + + async register(username: string, password: string): Promise<{ access_token: string }> { + const user = await this.userRepository.findOne({ where: { login: username } }); + if (user) throw new UnauthorizedException("User already exists"); + const salt = await bcrypt.genSalt(); + const hash = await bcrypt.hash(password, salt); + const newUser = this.userRepository.create({ + login: username, + password: hash, + }); + await this.userRepository.save(newUser); + this.logger.log(`User ${username} registered`); + const payload = { sub: newUser.uuid, username: newUser.login }; + return { access_token: await this.jwtService.signAsync(payload) }; + } } diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..2876373 --- /dev/null +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class LoginDto { + @ApiProperty({ description: "User name", example: "test" }) readonly username: string; + @ApiProperty({ description: "User password", example: "test" }) readonly password: string; +} diff --git a/backend/src/modules/auth/http-bearer.strategy.ts b/backend/src/modules/auth/http-bearer.strategy.ts deleted file mode 100644 index fd2aeb1..0000000 --- a/backend/src/modules/auth/http-bearer.strategy.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { Strategy } from 'passport-http-bearer'; -import { AuthService } from './auth.service'; -@Injectable() -export class HttpBearerStrategy extends PassportStrategy(Strategy) { - constructor(private readonly authService: AuthService) { - super(); - } - - async validate(token: string): Promise { - const user = await this.authService.authUserByToken(token); - if (!user) { - throw new UnauthorizedException(); - } - return user; - } -} diff --git a/backend/src/modules/auth/strategy/jwt.strategy.ts b/backend/src/modules/auth/strategy/jwt.strategy.ts new file mode 100644 index 0000000..bae8037 --- /dev/null +++ b/backend/src/modules/auth/strategy/jwt.strategy.ts @@ -0,0 +1,21 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { config } from "../../../../config"; +import { AuthService } from "../auth.service"; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.auth.jwt_secret, + }); + } + + async validate(payload: { iat: number; sub: string }): Promise { + const user = await this.authService.validateUserJwt(payload.sub); + if (!user) throw new UnauthorizedException(); + return user; + } +} diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index b206b2b..4b0aabc 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -1,9 +1,14 @@ -import { INestApplication } from '@nestjs/common'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { INestApplication } from "@nestjs/common"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; export function swagger(app: INestApplication): INestApplication { - const config = new DocumentBuilder().setTitle('Neuro website').setDescription('Some description').setVersion('0.1').build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); - return app; + const config = new DocumentBuilder() + .setTitle("Neuro website") + .setDescription("Some description") + .setVersion("0.1") + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup("api", app, document); + return app; } diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..8c6baaa Binary files /dev/null and b/bun.lockb differ diff --git a/docker-compose.yml b/docker-compose.yml index 2f19540..fbd6b7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,20 +49,19 @@ services: depends_on: - db restart: always - - bot: - container_name: neuro_bot - build: ./neuro-reply-bot-reworked - environment: - - API_URL=http://backend:3000 - - TZ=Europe/Moscow - - REDIS_HOST=redis - networks: - - labnet - depends_on: - - backend - - redis - restart: always + # bot: + # container_name: neuro_bot + # build: ./neuro-reply-bot-reworked + # environment: + # - API_URL=http://backend:3000 + # - TZ=Europe/Moscow + # - REDIS_HOST=redis + # networks: + # - labnet + # depends_on: + # - backend + # - redis + # restart: always volumes: neuro_postgres_db: diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e4cc93 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{ "dependencies": { "@nestjs/jwt": "^10.2.0" } } \ No newline at end of file