├── .gitignore ├── README.md ├── backend ├── .env ├── LICENSE ├── docker-compose.yml ├── ecosystem.config.js ├── nodemon.json ├── package.json ├── src │ ├── app.module.ts │ ├── auth │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.spec.ts │ │ ├── auth.service.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── index.ts │ │ │ └── jwt-payload.interface.ts │ │ └── jwt.strategy.ts │ ├── config │ │ ├── database.ts │ │ └── jwt.ts │ ├── entities │ │ ├── base.entity.ts │ │ ├── index.ts │ │ └── user.entity.ts │ ├── main.hmr.ts │ ├── main.ts │ ├── models │ │ ├── auth.model.ts │ │ ├── index.ts │ │ └── user.model.ts │ ├── roles.decorator.ts │ ├── roles.guard.ts │ └── user │ │ ├── index.ts │ │ ├── user.controller.ts │ │ ├── user.module.ts │ │ ├── user.service.spec.ts │ │ └── user.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── testdb.sh ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── yarn.lock └── frontend ├── .env ├── package.json ├── public ├── index.html ├── manifest.json └── robots.txt ├── src ├── App │ └── index.tsx ├── components │ ├── LayoutDefault │ │ ├── LayoutDefault.module.scss │ │ └── index.tsx │ ├── LayoutUser │ │ ├── LayoutUser.module.scss │ │ └── index.tsx │ └── VKButton │ │ ├── VKButton.module.scss │ │ ├── img │ │ └── VK_Blue_Logo.svg │ │ └── index.tsx ├── helpers │ └── isEmptyObj.ts ├── index.tsx ├── pages │ ├── 404 │ │ ├── NotFound.module.scss │ │ └── index.tsx │ ├── HomePage │ │ └── index.tsx │ ├── SignInPage │ │ ├── SignInPage.module.scss │ │ └── index.tsx │ └── UserPage │ │ ├── UserPage.module.scss │ │ └── index.tsx ├── react-app-env.d.ts ├── serviceWorker.ts ├── stores │ ├── UserStore.ts │ └── useStores.ts ├── styles │ └── main.scss └── types │ └── RequestState.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/ 3 | dist/ 4 | coverage/ 5 | .DS_Store 6 | pgdata 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | 66 | .DS_Store 67 | # next.js build output 68 | .next 69 | 70 | build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Авторизация через ВК NestJS + React 2 | 3 | Пример реализованной авторизации по способу Authorization code flow на примере NestJS + React. 4 | 5 | [Статья](https://medium.com/@ndrwbv/%D0%B0%D0%B2%D1%82%D0%BE%D1%80%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F-%D1%87%D0%B5%D1%80%D0%B5%D0%B7-%D0%B2%D0%BA-nestjs-react-797ff4e36154) 6 | 7 | # Установка 8 | 9 | ``` 10 | git clone git@github.com:ndrwbv/vk-auth-react-nestjs.git 11 | cd vk-auth-react-nestjs 12 | ``` 13 | 14 | ## Backend 15 | 16 | ```bash 17 | $ vk-auth-react-nestjs: cd backend 18 | $ vk-auth-react-nestjs/backend: docker-compose up -d 19 | $ vk-auth-react-nestjs/backend: yarn 20 | $ vk-auth-react-nestjs/backend: yarn dev 21 | ``` 22 | 23 | ## Frontend 24 | 25 | ```bash 26 | $ vk-auth-react-nestjs: cd frontend 27 | $ vk-auth-react-nestjs/frontend: yarn 28 | $ vk-auth-react-nestjs/frontend: yarn dev 29 | ``` 30 | 31 | # Настройка 32 | 33 | Для работы с вашим приложением необходимо: 34 | 35 | 1. Создать приложение во Вконтакте https://vk.com/editapp?act=create 36 | 2. Отредактировать файлы .env в папке frontend и backend 37 | 38 | ```bash 39 | frontend/.env: REACT_APP_CLIENT_ID -- ID приложения 40 | backend/.env: CLIENT_ID -- ID приложения 41 | backend/.env: CLIENT_SECRET -- Защищённый ключ 42 | ``` 43 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=root 2 | POSTGRES_PASSWORD=root 3 | POSTGRES_DB=test 4 | 5 | DB_HOST=localhost 6 | DB_PORT=5432 7 | DB_USER=root 8 | DB_PASSWORD=root 9 | DB_DATABASE=test 10 | DB_ENTITIES=src/entities/*.entity.ts 11 | DB_SYNCRONIZE=true 12 | DB_LOGGING=true 13 | DB_MIGRATIONS_RUN=true 14 | DB_MIGRATIONS_DIR=migrations 15 | 16 | 17 | JWT_SECRET=OPrivet 18 | JWT_EXPIRES=3600m 19 | 20 | APP_HOST=https://site.com 21 | APP_LOCAL=http://localhost:3000 22 | 23 | CLIENT_ID=7552509 24 | CLIENT_SECRET=NKzXhiDnRfSMN9McnePH 25 | 26 | PORT=8888 -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Aaryanna Simonelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | db: 4 | image: postgres 5 | env_file: .env 6 | volumes: 7 | - ./pgdata:/var/lib/postgresql/data 8 | ports: 9 | - '5432:5432' 10 | healthcheck: 11 | test: "cat /proc/net/tcp /proc/net/tcp6 | grep ':0CEB'" 12 | interval: 10s 13 | timeout: 5s 14 | retries: 5 15 | -------------------------------------------------------------------------------- /backend/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: "nestjs-postgres-auth-roles", 5 | script: "yarn", 6 | args: "start:prod", 7 | interpreter: "/bin/bash", 8 | env: { 9 | NODE_ENV: "development" 10 | } 11 | } 12 | ] 13 | }; 14 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [ 7 | "src/**/*.spec.ts" 8 | ], 9 | "exec": "ts-node -r tsconfig-paths/register src/main.ts" 10 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-postgres-auth-roles", 3 | "version": "1.0.0", 4 | "description": "nestjs-postgres-auth-roles", 5 | "author": "ndrwbv", 6 | "license": "MIT", 7 | "repository": "", 8 | "scripts": { 9 | "parse": "node -r dotenv/config parser/", 10 | "format": "prettier --write \"**/*.ts\"", 11 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 12 | "dev": "nodemon", 13 | "prestart:prod": "rm -rf dist && tsc", 14 | "start:prod": "nodemon dist/src/main.js", 15 | "start:hmr": "node dist/server", 16 | "test": "jest", 17 | "test:cov": "jest --coverage", 18 | "test:e2e": "jest --config ./test/jest-e2e.json", 19 | "webpack": "webpack --config webpack.config.js", 20 | "coveralls": "yarn run test:cov --coverageReporters=text-lcov | coveralls" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^7.3.2", 24 | "@nestjs/core": "^7.3.2", 25 | "@nestjs/jwt": "^7.1.0", 26 | "@nestjs/microservices": "^7.3.2", 27 | "@nestjs/passport": "^7.1.0", 28 | "@nestjs/platform-express": "^7.3.2", 29 | "@nestjs/schedule": "^0.4.0", 30 | "@nestjs/testing": "^7.3.2", 31 | "@nestjs/typeorm": "^7.1.0", 32 | "@nestjs/websockets": "^7.3.2", 33 | "@types/express": "^4.17.7", 34 | "@types/jest": "^26.0.7", 35 | "@types/node": "^14.0.27", 36 | "@types/supertest": "^2.0.10", 37 | "bcrypt": "^5.0.0", 38 | "class-transformer": "^0.3.1", 39 | "class-validator": "^0.12.2", 40 | "coveralls": "^3.1.0", 41 | "fastify-formbody": "^4.0.3", 42 | "jest": "^26.1.0", 43 | "minimist": "1.2.5", 44 | "mysql": "^2.18.1", 45 | "nestjs-config": "^1.4.7", 46 | "nodemon": "^2.0.4", 47 | "passport": "^0.4.1", 48 | "passport-http-bearer": "^1.0.1", 49 | "passport-jwt": "^4.0.0", 50 | "pg": "^8.3.0", 51 | "prettier": "^2.0.5", 52 | "reflect-metadata": "^0.1.13", 53 | "rxjs": "^6.6.0", 54 | "slugify": "^1.4.5", 55 | "supertest": "^4.0.2", 56 | "ts-jest": "^26.1.4", 57 | "ts-loader": "^8.0.1", 58 | "ts-node": "^8.10.2", 59 | "tsconfig-paths": "^3.9.0", 60 | "tslint": "^6.1.2", 61 | "typeorm": "^0.2.25", 62 | "typescript": "^3.9.7", 63 | "webpack": "^4.44.0", 64 | "webpack-cli": "^3.3.12", 65 | "webpack-node-externals": "^2.5.0" 66 | }, 67 | "devDependencies": {}, 68 | "jest": { 69 | "moduleFileExtensions": [ 70 | "js", 71 | "json", 72 | "ts" 73 | ], 74 | "rootDir": "src", 75 | "testRegex": ".spec.ts$", 76 | "transform": { 77 | "^.+\\.(t|j)s$": "ts-jest" 78 | }, 79 | "coverageDirectory": "../coverage", 80 | "collectCoverageFrom": [ 81 | "!src/config/**", 82 | "!src/models/**", 83 | "!src/entities/**" 84 | ] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { Module } from '@nestjs/common'; 4 | import { ConfigModule, ConfigService } from 'nestjs-config'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { ScheduleModule } from '@nestjs/schedule'; 7 | 8 | import { AuthModule } from './auth'; 9 | import { UserModule } from './user'; 10 | 11 | @Module({ 12 | imports: [ 13 | ScheduleModule.forRoot(), 14 | ConfigModule.load(path.resolve(__dirname, 'config', '*.{ts,js}')), 15 | TypeOrmModule.forRootAsync({ 16 | useFactory: (config: ConfigService) => config.get('database'), 17 | inject: [ConfigService], 18 | }), 19 | 20 | UserModule, 21 | AuthModule, 22 | ], 23 | }) 24 | export class AppModule {} 25 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | ValidationPipe, 6 | UnprocessableEntityException, 7 | } from "@nestjs/common"; 8 | import { AuthModel, UserModel, AuthVK, IGrant, UserDTO } from "./../models"; 9 | import { AuthService } from "./auth.service"; 10 | import { UserService } from "./../user"; 11 | 12 | @Controller("auth") 13 | export class AuthController { 14 | constructor( 15 | private readonly authService: AuthService, 16 | private readonly userService: UserService 17 | ) {} 18 | 19 | @Post("/login/vk") 20 | async vk(@Body(new ValidationPipe()) auth: AuthVK): Promise { 21 | let authData; 22 | 23 | try { 24 | authData = await this.authService.getVkToken(auth.code); 25 | } catch (err) { 26 | throw new UnprocessableEntityException("Wrong VK code"); 27 | } 28 | 29 | const hasEmail = authData.data.hasOwnProperty("email"); 30 | 31 | const _user = hasEmail 32 | ? await this.userService.findByEmail(authData.data.email) 33 | : await this.userService.findByVkId(authData.data.user_id); 34 | 35 | if (_user) { 36 | return this.authService.authenticate(_user, true); 37 | } 38 | 39 | try { 40 | const { data } = await this.authService.getUserDataFromVk( 41 | authData.data.user_id, 42 | authData.data.access_token 43 | ); 44 | 45 | const profile = data.response[0]; 46 | 47 | let user: UserModel = { 48 | vk_id: authData.data.user_id, 49 | email: authData.data.email, 50 | password: null, 51 | name: `${profile.first_name} ${profile.last_name}`, 52 | avatar_url: profile.photo_400, 53 | grant: IGrant.USER, 54 | }; 55 | 56 | await this.userService.create(user); 57 | 58 | return this.authService.authenticate(user, true); 59 | } catch (err) { 60 | throw new UnprocessableEntityException(err); 61 | } 62 | } 63 | 64 | @Post("/login") 65 | async login(@Body(new ValidationPipe()) auth: AuthModel): Promise { 66 | return this.authService.authenticate(auth); 67 | } 68 | 69 | @Post("/register") 70 | async register( 71 | @Body(new ValidationPipe()) userModel: UserModel 72 | ): Promise { 73 | const emailExists = await this.userService.findByEmail(userModel.email); 74 | 75 | if (emailExists) { 76 | throw new UnprocessableEntityException("Email already exists!"); 77 | } 78 | 79 | await this.userService.create(userModel); 80 | 81 | return this.authService.authenticate(userModel); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, HttpModule } from "@nestjs/common"; 2 | import { UserModule } from "./../user"; 3 | import { AuthService } from "./auth.service"; 4 | import { JwtModule } from "@nestjs/jwt"; 5 | import { PassportModule } from "@nestjs/passport"; 6 | import { ConfigModule, ConfigService } from "nestjs-config"; 7 | import { AuthController } from "./auth.controller"; 8 | import { JwtStrategy } from "./jwt.strategy"; 9 | 10 | @Module({ 11 | imports: [ 12 | HttpModule, 13 | UserModule, 14 | ConfigModule, 15 | PassportModule.register({ defaultStrategy: "jwt" }), 16 | JwtModule.registerAsync({ 17 | useFactory: (config: ConfigService) => config.get("jwt"), 18 | inject: [ConfigService], 19 | }), 20 | ], 21 | providers: [AuthService, JwtStrategy], 22 | controllers: [AuthController], 23 | }) 24 | export class AuthModule {} 25 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import { INestApplication, BadRequestException } from "@nestjs/common"; 4 | import { Test, TestingModule } from "@nestjs/testing"; 5 | import { TypeOrmModule } from "@nestjs/typeorm"; 6 | import { ConfigModule, ConfigService } from "nestjs-config"; 7 | 8 | import { AuthService } from "./auth.service"; 9 | import { UserModule, UserService } from "../user"; 10 | import { AuthModule } from "./auth.module"; 11 | import { UserEntity } from "../entities"; 12 | 13 | describe("AuthService", () => { 14 | let app: INestApplication; 15 | let module: TestingModule; 16 | let authService: AuthService; 17 | let payload: string; 18 | let userService: UserService; 19 | let user: UserEntity; 20 | 21 | beforeAll(async () => { 22 | module = await Test.createTestingModule({ 23 | imports: [ 24 | ConfigModule.load(path.resolve(__dirname, "../", "config", "*.ts")), 25 | TypeOrmModule.forRootAsync({ 26 | useFactory: (config: ConfigService) => config.get("database"), 27 | inject: [ConfigService], 28 | }), 29 | UserModule, 30 | AuthModule, 31 | ], 32 | }).compile(); 33 | 34 | app = module.createNestApplication(); 35 | await app.init(); 36 | 37 | authService = module.get(AuthService); 38 | userService = module.get(UserService); 39 | }); 40 | 41 | it("authenticate fail", async () => { 42 | let error; 43 | try { 44 | await authService.authenticate({ 45 | email: "no email", 46 | password: "df", 47 | }); 48 | } catch (e) { 49 | error = e; 50 | } 51 | 52 | expect(error).toBeInstanceOf(BadRequestException); 53 | }); 54 | 55 | it("authenticate", async () => { 56 | user = await userService.create({ 57 | email: "email@email.com", 58 | password: "testtest", 59 | name: "test", 60 | grant: 1, 61 | }); 62 | 63 | payload = await authService.authenticate({ 64 | email: "email@email.com", 65 | password: "testtest", 66 | }); 67 | }); 68 | 69 | it("validateUser", async () => { 70 | const result = await authService.validateUser(user); 71 | expect(result).toBeInstanceOf(UserEntity); 72 | }); 73 | 74 | afterAll(async () => { 75 | await userService.destroy(user.id); 76 | app.close(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, BadRequestException, HttpService } from "@nestjs/common"; 2 | import { JwtService } from "@nestjs/jwt"; 3 | 4 | import { UserService } from "./../user"; 5 | import { UserEntity } from "../entities"; 6 | import { JwtPayloadInterface } from "./interfaces"; 7 | import { AuthModel, UserDTO } from "../models"; 8 | 9 | @Injectable() 10 | export class AuthService { 11 | constructor( 12 | private readonly userService: UserService, 13 | private readonly jwtService: JwtService, 14 | private http: HttpService 15 | ) {} 16 | 17 | async validateUser(payload: JwtPayloadInterface): Promise { 18 | return await this.userService.findById(payload.id); 19 | } 20 | 21 | async authenticate( 22 | auth: AuthModel, 23 | skipPasswordCheck: boolean = false 24 | ): Promise { 25 | const user = await this.userService.findByEmailWithPassword(auth.email); 26 | 27 | if (!user) { 28 | throw new BadRequestException(); 29 | } 30 | 31 | const isRightPassword = 32 | user.password && !skipPasswordCheck 33 | ? await this.userService.compareHash(auth.password, user.password) 34 | : true; 35 | 36 | if (!isRightPassword) { 37 | throw new BadRequestException("Invalid credentials"); 38 | } 39 | 40 | return { 41 | id: user.id, 42 | vk_id: user.vk_id, 43 | email: user.email, 44 | grant: user.grant, 45 | name: user.name, 46 | avatar_url: user.avatar_url, 47 | token: await this.jwtService.sign({ id: user.id }), 48 | }; 49 | } 50 | 51 | async getVkToken(code: string): Promise { 52 | const VKDATA = { 53 | client_id: process.env.CLIENT_ID, 54 | client_secret: process.env.CLIENT_SECRET, 55 | }; 56 | 57 | const host = 58 | process.env.NODE_ENV === "prod" 59 | ? process.env.APP_HOST 60 | : process.env.APP_LOCAL; 61 | 62 | return this.http 63 | .get( 64 | `https://oauth.vk.com/access_token?client_id=${VKDATA.client_id}&client_secret=${VKDATA.client_secret}&redirect_uri=${host}/signin&code=${code}` 65 | ) 66 | .toPromise(); 67 | } 68 | 69 | async getUserDataFromVk(userId: string, token: string): Promise { 70 | return this.http 71 | .get( 72 | `https://api.vk.com/method/users.get?user_ids=${userId}&fields=photo_400,has_mobile,home_town,contacts,mobile_phone&access_token=${token}&v=5.120` 73 | ) 74 | .toPromise(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /backend/src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.module'; 2 | export * from './auth.service'; 3 | export * from './interfaces'; 4 | -------------------------------------------------------------------------------- /backend/src/auth/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt-payload.interface'; 2 | -------------------------------------------------------------------------------- /backend/src/auth/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayloadInterface { 2 | id: number; 3 | } 4 | -------------------------------------------------------------------------------- /backend/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { AuthService } from './auth.service'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 5 | import { JwtPayloadInterface } from './interfaces'; 6 | import { UserEntity } from 'entities'; 7 | import { InjectConfig, ConfigService } from 'nestjs-config'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor( 12 | private readonly authService: AuthService, 13 | @InjectConfig() config: ConfigService, 14 | ) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 17 | secretOrKey: config.get('jwt.secretOrPrivateKey'), 18 | }); 19 | } 20 | 21 | async validate(payload: JwtPayloadInterface): Promise { 22 | const user = await this.authService.validateUser(payload); 23 | if (!user) { 24 | throw new UnauthorizedException(); 25 | } 26 | return user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/config/database.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | host: process.env.DB_HOST, 3 | type: 'postgres', 4 | port: process.env.DB_PORT, 5 | username: process.env.DB_USER, 6 | password: process.env.DB_PASSWORD, 7 | database: process.env.DB_DATABASE, 8 | entities: [process.env.DB_ENTITIES], 9 | synchronize: process.env.DB_SYNCRONIZE === 'true', 10 | logging: process.env.DB_LOGGING === 'true', 11 | migrationsRun: process.env.DB_MIGRATIONS_RUN === 'true', 12 | migrationsDir: [process.env.DB_MIGRATIONS_DIR], 13 | extra: { 14 | charset: 'utf8mb4_unicode_ci', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /backend/src/config/jwt.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | secretOrPrivateKey: process.env.JWT_SECRET, 3 | signOptions: { 4 | expiresIn: process.env.JWT_EXPIRES, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /backend/src/entities/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PrimaryGeneratedColumn, 3 | CreateDateColumn, 4 | UpdateDateColumn, 5 | } from 'typeorm'; 6 | 7 | abstract class BaseEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @CreateDateColumn() 12 | created; 13 | 14 | @UpdateDateColumn() 15 | updated; 16 | } 17 | 18 | export default BaseEntity; 19 | -------------------------------------------------------------------------------- /backend/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user.entity"; 2 | -------------------------------------------------------------------------------- /backend/src/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import BaseEntity from "./base.entity"; 2 | import { Entity, Column } from "typeorm"; 3 | 4 | @Entity() 5 | export class UserEntity extends BaseEntity { 6 | @Column({ 7 | nullable: true, 8 | }) 9 | vk_id: number; 10 | 11 | @Column() 12 | name: string; 13 | 14 | @Column({ 15 | unique: true, 16 | }) 17 | email: string; 18 | 19 | @Column({ 20 | select: false, 21 | nullable: true, 22 | }) 23 | password: string; 24 | 25 | @Column({ 26 | nullable: false, 27 | }) 28 | grant: number; 29 | 30 | @Column() 31 | avatar_url: string; 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/main.hmr.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | declare const module: any; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | await app.listen(3000); 9 | 10 | if (module.hot) { 11 | module.hot.accept(); 12 | module.hot.dispose(() => app.close()); 13 | } 14 | } 15 | bootstrap(); 16 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from "@nestjs/core"; 2 | import { AppModule } from "./app.module"; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule, { cors: true }); 6 | await app.listen(process.env.PORT); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /backend/src/models/auth.model.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from "class-validator"; 2 | 3 | export class AuthModel { 4 | @IsEmail() 5 | email: string; 6 | 7 | @IsString() 8 | password: string; 9 | } 10 | 11 | export class AuthVK { 12 | code: string; 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user.model"; 2 | export * from "./auth.model"; 3 | -------------------------------------------------------------------------------- /backend/src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNumber, IsEmail } from "class-validator"; 2 | 3 | export enum IGrant { 4 | ADMIN = 0, 5 | USER = 1, 6 | } 7 | 8 | export class UserModel { 9 | vk_id?: number; 10 | 11 | @IsEmail() 12 | email: string; 13 | 14 | @IsString() 15 | name: string; 16 | 17 | @IsString() 18 | password: string; 19 | 20 | @IsNumber() 21 | grant: IGrant; 22 | 23 | avatar_url?: string; 24 | } 25 | 26 | export class UserDTO { 27 | id: number; 28 | vk_id: number; 29 | email: string; 30 | name: string; 31 | grant: IGrant; 32 | avatar_url: string; 33 | token: string; 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Roles = (...roles: string[]) => SetMetadata('roles', roles); 4 | -------------------------------------------------------------------------------- /backend/src/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { IGrant } from 'models'; 4 | import { UserEntity } from 'entities'; 5 | 6 | @Injectable() 7 | export class RolesGuard implements CanActivate { 8 | constructor(private readonly reflector: Reflector) {} 9 | 10 | canActivate(context: ExecutionContext): boolean { 11 | const roles = this.reflector.get('roles', context.getHandler()); 12 | if (!roles) { 13 | return true; 14 | } 15 | const request = context.switchToHttp().getRequest(); 16 | const user = request.user; 17 | 18 | return this.matchRoles(roles, user); 19 | } 20 | 21 | matchRoles(grants: string[], user: UserEntity) { 22 | return grants.some(grant => grant.toUpperCase() === IGrant[user.grant]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.module'; 2 | export * from './user.service'; 3 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | ValidationPipe, 6 | UnprocessableEntityException, 7 | Put, 8 | Get, 9 | Param, 10 | NotFoundException, 11 | UseGuards, 12 | Request, 13 | } from "@nestjs/common"; 14 | import { AuthGuard } from "@nestjs/passport"; 15 | 16 | import { Roles } from "roles.decorator"; 17 | import { RolesGuard } from "roles.guard"; 18 | 19 | import { UserModel } from "./../models"; 20 | import { UserEntity } from "../entities"; 21 | import { UserService } from "./user.service"; 22 | 23 | @Controller("users") 24 | export class UserController { 25 | constructor(private readonly userService: UserService) {} 26 | @Get("/profile") 27 | @UseGuards(AuthGuard("jwt"), RolesGuard) 28 | @Roles("admin", "user") 29 | async getProfile(@Request() req): Promise { 30 | return req.user; 31 | } 32 | 33 | @Get("/:id") 34 | @UseGuards(AuthGuard("jwt"), RolesGuard) 35 | @Roles("admin") 36 | async getUserById(@Param("id") id: number): Promise { 37 | const user = this.userService.findById(id); 38 | 39 | if (!user) { 40 | throw new NotFoundException(); 41 | } 42 | 43 | return user; 44 | } 45 | 46 | @Get() 47 | @UseGuards(AuthGuard("jwt"), RolesGuard) 48 | @Roles("admin") 49 | async getAllUsers(): Promise { 50 | return await this.userService.findAll(); 51 | } 52 | 53 | @Post() 54 | @UseGuards(AuthGuard("jwt"), RolesGuard) 55 | @Roles("admin") 56 | async addUser( 57 | @Body(new ValidationPipe()) userModel: UserModel 58 | ): Promise { 59 | const user = await this.userService.findByEmail(userModel.email); 60 | 61 | if (user) { 62 | throw new UnprocessableEntityException(); 63 | } 64 | 65 | return await this.userService.create(userModel); 66 | } 67 | 68 | @Put() 69 | @UseGuards(AuthGuard("jwt"), RolesGuard) 70 | @Roles("admin") 71 | async updateUser(@Body() user: UserModel): Promise { 72 | const userEntity = await this.userService.findByEmail(user.email); 73 | 74 | if (!userEntity) { 75 | throw new UnprocessableEntityException(); 76 | } 77 | 78 | return await this.userService.update(userEntity, user); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserEntity } from './../entities'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { UserController } from './user.controller'; 5 | import { UserService } from './user.service'; 6 | import { ConfigModule } from 'nestjs-config'; 7 | 8 | @Module({ 9 | imports: [ConfigModule, TypeOrmModule.forFeature([UserEntity])], 10 | controllers: [UserController], 11 | providers: [UserService], 12 | exports: [UserService], 13 | }) 14 | export class UserModule {} 15 | -------------------------------------------------------------------------------- /backend/src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import { Test, TestingModule } from "@nestjs/testing"; 4 | import { INestApplication } from "@nestjs/common"; 5 | import { TypeOrmModule } from "@nestjs/typeorm"; 6 | import { ConfigModule, ConfigService } from "nestjs-config"; 7 | import { UpdateResult, DeleteResult } from "typeorm"; 8 | 9 | import { UserEntity } from "../entities"; 10 | import { UserService, UserModule } from "./"; 11 | 12 | describe("UserService", () => { 13 | let module: TestingModule; 14 | let userService: UserService; 15 | let app: INestApplication; 16 | let user: UserEntity; 17 | let auth: Partial; 18 | 19 | beforeAll(async () => { 20 | module = await Test.createTestingModule({ 21 | imports: [ 22 | ConfigModule.load(path.resolve(__dirname, "../", "config", "*.ts")), 23 | TypeOrmModule.forRootAsync({ 24 | useFactory: (config: ConfigService) => config.get("database"), 25 | inject: [ConfigService], 26 | }), 27 | UserModule, 28 | ], 29 | }).compile(); 30 | 31 | app = await module.createNestApplication(); 32 | 33 | userService = module.get(UserService); 34 | }); 35 | 36 | it("create", async () => { 37 | const number = Math.floor(Math.random() * Math.floor(20)); 38 | expect( 39 | (user = await userService.create({ 40 | email: `test${number}@test.com`, 41 | name: "test", 42 | grant: 1, 43 | password: "password", 44 | })) 45 | ).toBeInstanceOf(UserEntity); 46 | expect(user).not.toHaveProperty("password"); 47 | }); 48 | 49 | it("Update", async () => { 50 | const result = await userService.update(user, { ...user, name: "updated" }); 51 | expect(result).toBeInstanceOf(UpdateResult); 52 | expect(result.name).toEqual("updated"); 53 | }); 54 | 55 | it("findByEmailWithPassword", async () => { 56 | auth = await userService.findByEmailWithPassword(user.email); 57 | 58 | expect(auth).toHaveProperty("password"); 59 | }); 60 | 61 | it("CompareHash", async () => { 62 | const result = await userService.compareHash("password", auth.password); 63 | 64 | expect(result).toBeTruthy(); 65 | }); 66 | 67 | it("findByEmail", async () => { 68 | const result = await userService.findByEmail(user.email); 69 | 70 | expect(result).toBeInstanceOf(UserEntity); 71 | 72 | delete user.updated; 73 | 74 | expect(result).toEqual(expect.objectContaining(user)); 75 | }); 76 | 77 | it("findById", async () => { 78 | const result = await userService.findById(user.id); 79 | 80 | expect(result).toBeInstanceOf(UserEntity); 81 | expect(result).toEqual(expect.objectContaining(user)); 82 | }); 83 | 84 | it("delete", async () => { 85 | const result = await userService.destroy(user.id); 86 | expect(result).toBeInstanceOf(DeleteResult); 87 | }); 88 | 89 | afterAll(async () => app.close()); 90 | }); 91 | -------------------------------------------------------------------------------- /backend/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | 3 | import { Injectable } from "@nestjs/common"; 4 | import { InjectRepository } from "@nestjs/typeorm"; 5 | import { Repository, UpdateResult, DeleteResult } from "typeorm"; 6 | import { ConfigService, InjectConfig } from "nestjs-config"; 7 | 8 | import { UserEntity as User, UserEntity } from "./../entities"; 9 | import { UserModel } from "../models"; 10 | 11 | @Injectable() 12 | export class UserService { 13 | private saltRounds: number; 14 | 15 | constructor( 16 | @InjectRepository(User) private readonly userRepository: Repository, 17 | @InjectConfig() private readonly config: ConfigService 18 | ) { 19 | this.saltRounds = config.get("app.salt_rounds", 10); 20 | } 21 | 22 | async create(user: UserModel): Promise { 23 | const userToCreate = { 24 | ...user, 25 | password: user.password ? await this.getHash(user.password) : null, 26 | }; 27 | 28 | const result = await this.userRepository.save( 29 | this.userRepository.create(userToCreate) 30 | ); 31 | 32 | return result; 33 | } 34 | 35 | async update(userEntity: UserEntity, user: UserModel): Promise { 36 | return await this.userRepository.save({ 37 | ...userEntity, 38 | name: user.name, 39 | }); 40 | } 41 | 42 | async findByEmail(email: string): Promise { 43 | return await this.userRepository.findOne({ 44 | where: { 45 | email, 46 | }, 47 | }); 48 | } 49 | 50 | async findByVkId(vk_id: number): Promise { 51 | return await this.userRepository.findOne({ 52 | where: { 53 | vk_id, 54 | }, 55 | }); 56 | } 57 | 58 | async findById(id: number): Promise { 59 | return await this.userRepository.findOneOrFail(id); 60 | } 61 | 62 | async findAll(): Promise { 63 | return await this.userRepository.find(); 64 | } 65 | 66 | async getHash(password: string): Promise { 67 | return await bcrypt.hash(password, this.saltRounds); 68 | } 69 | 70 | async compareHash(password: string, hash: string): Promise { 71 | return await bcrypt.compare(password, hash); 72 | } 73 | 74 | async destroy(id: number): Promise { 75 | return await this.userRepository.delete(id); 76 | } 77 | 78 | async findByEmailWithPassword(email: string): Promise | null { 79 | return await this.userRepository.findOne( 80 | { 81 | email, 82 | }, 83 | { 84 | select: ["email", "password", "id", "grant", "name", "avatar_url"], 85 | } 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { ApplicationModule } from './../src/app.module'; 4 | import { INestApplication } from '@nestjs/common'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const moduleFixture = await Test.createTestingModule({ 11 | imports: [ApplicationModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/GET /', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | 25 | afterAll(() => { 26 | app.close(); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testRegex": ".e2e-spec.ts$", 5 | "transform": { 6 | "^.+\\.(t|j)s$": "ts-jest" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/testdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | while [ "$(docker inspect -f '{{.State.Health.Status}}' $(docker ps --last 1 --format '{{.Names}}'))" != "healthy" ]; 3 | do 4 | docker-compose logs db 5 | sleep 1 6 | done -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": false, 5 | "noImplicitAny": false, 6 | "removeComments": true, 7 | "noLib": false, 8 | "allowSyntheticDefaultImports": true, 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es6", 12 | "sourceMap": true, 13 | "allowJs": true, 14 | "outDir": "./dist", 15 | "baseUrl": "./src" 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "**/*.spec.ts" 23 | ] 24 | } -------------------------------------------------------------------------------- /backend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": { 7 | "no-unused-expression": true 8 | }, 9 | "rules": { 10 | "eofline": false, 11 | "quotemark": [ 12 | true, 13 | "single" 14 | ], 15 | "indent": false, 16 | "member-access": [ 17 | false 18 | ], 19 | "ordered-imports": [ 20 | false 21 | ], 22 | "max-line-length": [ 23 | true, 24 | 150 25 | ], 26 | "member-ordering": [ 27 | false 28 | ], 29 | "curly": false, 30 | "interface-name": [ 31 | false 32 | ], 33 | "array-type": [ 34 | false 35 | ], 36 | "no-empty-interface": false, 37 | "no-empty": false, 38 | "arrow-parens": false, 39 | "object-literal-sort-keys": false, 40 | "no-unused-expression": false, 41 | "max-classes-per-file": [ 42 | false 43 | ], 44 | "variable-name": [ 45 | false 46 | ], 47 | "one-line": [ 48 | false 49 | ], 50 | "one-variable-per-declaration": [ 51 | false 52 | ] 53 | }, 54 | "rulesDirectory": [] 55 | } -------------------------------------------------------------------------------- /backend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | entry: ['webpack/hot/poll?1000', './src/main.hmr.ts'], 7 | watch: true, 8 | target: 'node', 9 | externals: [ 10 | nodeExternals({ 11 | whitelist: ['webpack/hot/poll?1000'], 12 | }), 13 | ], 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | use: 'ts-loader', 19 | exclude: /node_modules/, 20 | }, 21 | ], 22 | }, 23 | mode: "development", 24 | resolve: { 25 | extensions: ['.tsx', '.ts', '.js'], 26 | }, 27 | plugins: [ 28 | new webpack.HotModuleReplacementPlugin(), 29 | ], 30 | output: { 31 | path: path.join(__dirname, 'dist'), 32 | filename: 'server.js', 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_HOST=https://api.site.com/ 2 | REACT_APP_HOST_PROD=https://site.com 3 | REACT_APP_CLIENT_ID=7552509 4 | REACT_APP_HOST_LOCAL=http://localhost:3000 5 | REACT_APP_MODE=development 6 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vk-auth-react", 3 | "version": "0.0.1", 4 | "private": true, 5 | "author": "Andrew Boev", 6 | "dependencies": { 7 | "@types/node": "^14.0.27", 8 | "@types/react": "^16.9.43", 9 | "@types/react-dom": "^16.9.8", 10 | "@types/react-router-dom": "^5.1.5", 11 | "mobx": "^5.15.4", 12 | "mobx-react": "^6.2.5", 13 | "node-sass": "^7.0.0", 14 | "react": "^16.13.1", 15 | "react-dom": "^16.13.1", 16 | "react-router-dom": "^5.2.0", 17 | "react-scripts": "3.4.1", 18 | "typescript": "~3.9.7" 19 | }, 20 | "scripts": { 21 | "dev": "cross-env REACT_APP_API_HOST=http://localhost:8888 react-scripts start", 22 | "build": "cross-env REACT_APP_MODE=production react-scripts build", 23 | "prod": "cross-env REACT_APP_MODE=production react-scripts start", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": "react-app" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "devDependencies": { 43 | "concurrently": "^5.1.0", 44 | "cross-env": "^7.0.2", 45 | "prettier": "2.0.5", 46 | "serve": "^11.3.2", 47 | "typed-css-modules": "^0.6.4", 48 | "typescript-plugin-css-modules": "^2.4.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | vk-auth-react-nestjs 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ВКампусе", 3 | "name": "ВКампусе", 4 | "icons": [], 5 | "start_url": ".", 6 | "display": "standalone", 7 | "theme_color": "#000000", 8 | "background_color": "#ffffff" 9 | } 10 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter, Switch, Route } from "react-router-dom"; 3 | 4 | import NotFound from "pages/404"; 5 | import SignInPage from "pages/SignInPage"; 6 | import UserPage from "pages/UserPage"; 7 | import HomePage from "pages/HomePage"; 8 | 9 | interface IProps {} 10 | 11 | const App: React.FC = () => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /frontend/src/components/LayoutDefault/LayoutDefault.module.scss: -------------------------------------------------------------------------------- 1 | .layout-default { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | height: 100%; 6 | 7 | padding: 40px; 8 | box-sizing: border-box; 9 | } 10 | 11 | .layout-default__links { 12 | width: 100%; 13 | max-width: 1300px; 14 | 15 | display: flex; 16 | justify-content: space-between; 17 | } 18 | 19 | .layout-default__content { 20 | width: 100%; 21 | height: 100%; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | flex-direction: column; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/LayoutDefault/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { observer } from "mobx-react"; 3 | import { Link, useHistory } from "react-router-dom"; 4 | 5 | import { useStores } from "stores/useStores"; 6 | import { RequestState } from "types/RequestState"; 7 | 8 | import styles from "./LayoutDefault.module.scss"; 9 | 10 | const PRIVATE_ROUTES = ["/user"]; 11 | 12 | const LayoutDefault: React.FC = observer((props) => { 13 | const { user, state, getProfile, logout } = useStores()["UserStore"]; 14 | let history = useHistory(); 15 | 16 | React.useEffect(() => { 17 | const token = sessionStorage.getItem("token"); 18 | 19 | if (PRIVATE_ROUTES.includes(history.location.pathname) && !token) 20 | return history.push("/signin"); 21 | 22 | if (!user && state !== RequestState.LOADING && token) { 23 | getProfile().catch(() => { 24 | history.push("/signin"); 25 | logout(); 26 | }); 27 | } 28 | }, [user, state, getProfile, logout, history]); 29 | 30 | return ( 31 |
32 |
33 | Главная 34 | {user ? ( 35 | Мой профиль 36 | ) : ( 37 | Войти 38 | )} 39 |
40 |
{props.children}
41 |
42 | ); 43 | }); 44 | 45 | export default LayoutDefault; 46 | -------------------------------------------------------------------------------- /frontend/src/components/LayoutUser/LayoutUser.module.scss: -------------------------------------------------------------------------------- 1 | .layout-user { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/components/LayoutUser/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import LayoutDefault from "components/LayoutDefault"; 4 | 5 | import styles from "./LayoutUser.module.scss"; 6 | 7 | interface IProps {} 8 | 9 | const LayoutUser: React.FC = (props) => { 10 | return ( 11 | 12 |
13 |
14 |
{props.children}
15 |
16 |
17 |
18 | ); 19 | }; 20 | 21 | export default LayoutUser; 22 | -------------------------------------------------------------------------------- /frontend/src/components/VKButton/VKButton.module.scss: -------------------------------------------------------------------------------- 1 | .vk-button__input { 2 | display: flex; 3 | 4 | align-items: center; 5 | color: #4680c2; 6 | border: none; 7 | border-radius: 10px; 8 | font-weight: bold; 9 | padding-right: 20px; 10 | cursor: pointer; 11 | 12 | &:hover { 13 | opacity: 0.8; 14 | } 15 | } 16 | 17 | .vk-button { 18 | margin-top: 20px; 19 | text-align: center; 20 | } 21 | 22 | .vk-button__input-icon { 23 | margin-right: 10px; 24 | width: 60px; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/VKButton/img/VK_Blue_Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 11 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/components/VKButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withRouter, RouteComponentProps } from "react-router-dom"; 3 | import queryString from "query-string"; 4 | 5 | import { isEmptyObj } from "helpers/isEmptyObj"; 6 | import UserStore from "stores/UserStore"; 7 | 8 | import vk from "./img/VK_Blue_Logo.svg"; 9 | 10 | import styles from "./VKButton.module.scss"; 11 | 12 | interface IProps extends RouteComponentProps {} 13 | 14 | const VKButton: React.FC = (props) => { 15 | const [isError, setIsError] = React.useState(false); 16 | 17 | const host = 18 | process.env.REACT_APP_MODE === "production" 19 | ? process.env.REACT_APP_HOST_PROD 20 | : process.env.REACT_APP_HOST_LOCAL; 21 | 22 | const cbLink = `${host}/signin`; 23 | 24 | const handleRedirect = () => { 25 | window.location.href = `https://oauth.vk.com/authorize?client_id=${process.env.REACT_APP_CLIENT_ID}&display=popup&redirect_uri=${cbLink}&scope=email&response_type=code&v=5.120&state=4194308`; 26 | }; 27 | 28 | React.useEffect(() => { 29 | const handleLogin = (code: string) => { 30 | UserStore.loginVk(code) 31 | .then(() => { 32 | props.history.push("/user"); 33 | }) 34 | .catch(() => setIsError(true)); 35 | }; 36 | 37 | let queryObj = queryString.parse(props.location.search); 38 | 39 | if (isError) window.location.href = cbLink; 40 | 41 | if (!isEmptyObj(queryObj) && queryObj["code"]) handleLogin(queryObj.code); 42 | }, [props.location.search, isError, cbLink, props.history]); 43 | 44 | return ( 45 |
46 | 54 | {isError &&

Ошибка входа через ВК

} 55 |
56 | ); 57 | }; 58 | 59 | export default withRouter(VKButton); 60 | -------------------------------------------------------------------------------- /frontend/src/helpers/isEmptyObj.ts: -------------------------------------------------------------------------------- 1 | export const isEmptyObj = (obj: Object): boolean => { 2 | for (let key in obj) { 3 | return false; 4 | } 5 | return true; 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "mobx-react"; 4 | 5 | import * as serviceWorker from "./serviceWorker"; 6 | 7 | import App from "App"; 8 | 9 | import UserStore from "stores/UserStore"; 10 | 11 | import "styles/main.scss"; 12 | 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById("root") 18 | ); 19 | 20 | // If you want your app to work offline and load faster, you can change 21 | // unregister() to register() below. Note this comes with some pitfalls. 22 | // Learn more about service workers: https://bit.ly/CRA-PWA 23 | serviceWorker.unregister(); 24 | -------------------------------------------------------------------------------- /frontend/src/pages/404/NotFound.module.scss: -------------------------------------------------------------------------------- 1 | .page { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | padding: 100px 0 0; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/pages/404/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import styles from "./NotFound.module.scss"; 5 | 6 | interface IProps {} 7 | const NotFound: React.FC = () => { 8 | return ( 9 |
10 |

Страница не найдена

11 | Вернуться на главную 12 |
13 | ); 14 | }; 15 | 16 | export default NotFound; 17 | -------------------------------------------------------------------------------- /frontend/src/pages/HomePage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { observer } from "mobx-react"; 3 | 4 | import { useStores } from "stores/useStores"; 5 | import { IUser } from "stores/UserStore"; 6 | 7 | import LayoutDefault from "components/LayoutDefault"; 8 | 9 | const HomePage = observer(() => { 10 | const user: IUser = useStores()["UserStore"].user; 11 | 12 | return ( 13 | 14 |

Home Page

15 | {user &&

О привет, {user.name}

} 16 |
17 | ); 18 | }); 19 | 20 | export default HomePage; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/SignInPage/SignInPage.module.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndrwbv/vk-auth-react-nestjs/09bde01a083d50fcff6ec4b7c662f5975ce81d5c/frontend/src/pages/SignInPage/SignInPage.module.scss -------------------------------------------------------------------------------- /frontend/src/pages/SignInPage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import VKButton from "components/VKButton"; 4 | import LayoutDefault from "components/LayoutDefault"; 5 | 6 | import styles from "./SignInPage.module.scss"; 7 | 8 | const SignInPage: React.FC = () => { 9 | return ( 10 | 11 |
12 | 13 |
14 |
15 | ); 16 | }; 17 | 18 | export default SignInPage; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/UserPage/UserPage.module.scss: -------------------------------------------------------------------------------- 1 | .user-page { 2 | text-align: center; 3 | 4 | .user-page__name { 5 | font-weight: bold; 6 | font-size: 24px; 7 | } 8 | 9 | .user-page__email { 10 | color: rgba(119, 119, 119, 0.8); 11 | } 12 | 13 | p + p { 14 | margin-top: 5px; 15 | } 16 | 17 | .user-page__logout { 18 | width: 120px; 19 | cursor: pointer; 20 | padding: 10px 20px; 21 | margin-top: 20px; 22 | } 23 | 24 | .user-page__avatar { 25 | border-radius: 50%; 26 | margin-bottom: 20px; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/pages/UserPage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { observer, inject } from "mobx-react"; 3 | import { RouteComponentProps, withRouter } from "react-router-dom"; 4 | 5 | import LayoutUser from "components/LayoutUser"; 6 | 7 | import { RequestState } from "types/RequestState"; 8 | import UserStore from "stores/UserStore"; 9 | 10 | import styles from "./UserPage.module.scss"; 11 | 12 | interface IProps extends RouteComponentProps { 13 | UserStore: typeof UserStore; 14 | } 15 | 16 | class UserPage extends React.Component { 17 | componentDidMount() { 18 | const token = sessionStorage.getItem("token"); 19 | if (!token) return this.props.history.push("/signin"); 20 | } 21 | 22 | handleLogout = () => { 23 | UserStore.logout(); 24 | this.props.history.push("/"); 25 | }; 26 | 27 | render() { 28 | const { state, user } = this.props.UserStore; 29 | 30 | if (state === RequestState.ERROR) return

Erorr

; 31 | 32 | if (state !== RequestState.SUCCESS) { 33 | return ( 34 | 35 |

Loading...

36 |
37 | ); 38 | } 39 | 40 | return ( 41 | 42 |
43 | vk avatar 48 |

{user.name}

49 |

{user.email}

50 | 56 |
57 |
58 | ); 59 | } 60 | } 61 | 62 | export default inject("UserStore")(withRouter(observer(UserPage))); 63 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /frontend/src/stores/UserStore.ts: -------------------------------------------------------------------------------- 1 | import { observable, decorate } from "mobx"; 2 | 3 | import { RequestState } from "types/RequestState"; 4 | 5 | export interface IUser { 6 | id: number; 7 | email: string; 8 | grant: number; 9 | name: string; 10 | token: string; 11 | avatar_url: string | null; 12 | } 13 | 14 | class UserStore { 15 | user: IUser = null; 16 | state = RequestState.PENDING; 17 | 18 | loginVk = (code: string) => { 19 | this.state = RequestState.LOADING; 20 | 21 | return fetch(`${process.env.REACT_APP_API_HOST}/auth/login/vk`, { 22 | method: "POST", 23 | body: JSON.stringify({ code }), 24 | headers: { 25 | "Content-Type": "application/json", 26 | }, 27 | }) 28 | .then((res) => { 29 | switch (res.status) { 30 | case 200: 31 | case 201: 32 | return res.json(); 33 | default: 34 | this.setError(); 35 | return Promise.reject(); 36 | } 37 | }) 38 | .then((user) => this.setUser(user)); 39 | }; 40 | 41 | getProfile = () => { 42 | this.state = RequestState.LOADING; 43 | const token = sessionStorage.getItem("token"); 44 | 45 | if (!token) Promise.reject(); 46 | 47 | return fetch(`${process.env.REACT_APP_API_HOST}/users/profile`, { 48 | headers: { 49 | "Content-Type": "application/json", 50 | Authorization: `Bearer ${token}`, 51 | }, 52 | }) 53 | .then((res) => { 54 | switch (res.status) { 55 | case 200: 56 | case 201: 57 | return res.json(); 58 | default: 59 | this.setError(); 60 | return Promise.reject(); 61 | } 62 | }) 63 | .then((user) => this.setUser(user)); 64 | }; 65 | 66 | logout = () => { 67 | this.user = null; 68 | sessionStorage.clear(); 69 | this.state = RequestState.PENDING; 70 | }; 71 | 72 | setUser = (user: IUser) => { 73 | this.user = user; 74 | this.state = RequestState.SUCCESS; 75 | 76 | if (user["token"]) { 77 | sessionStorage.setItem("token", user.token); 78 | } 79 | }; 80 | 81 | setError = () => { 82 | this.state = RequestState.ERROR; 83 | }; 84 | } 85 | 86 | decorate(UserStore, { 87 | user: observable, 88 | state: observable, 89 | }); 90 | 91 | export default new UserStore(); 92 | -------------------------------------------------------------------------------- /frontend/src/stores/useStores.ts: -------------------------------------------------------------------------------- 1 | import { MobXProviderContext } from "mobx-react"; 2 | import React from "react"; 3 | 4 | export function useStores() { 5 | return React.useContext(MobXProviderContext); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/styles/main.scss: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: "Roboto", sans-serif !important; 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | html { 11 | scroll-behavior: smooth; 12 | height: 100%; 13 | } 14 | 15 | body { 16 | height: 100%; 17 | } 18 | 19 | #root { 20 | height: 100%; 21 | } 22 | 23 | .async-hide { 24 | opacity: 0 !important; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/types/RequestState.ts: -------------------------------------------------------------------------------- 1 | export enum RequestState { 2 | PENDING = "pending", 3 | LOADING = "loading", 4 | SUCCESS = "success", 5 | ERROR = "error" 6 | } 7 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react" 18 | // "plugins": [{ "name": "typescript-plugin-css-modules" }] 19 | }, 20 | "include": ["src"] 21 | } 22 | --------------------------------------------------------------------------------