[JavaScript, Программирование, Node.JS] RESTful backend приложение. Базовый шаблон
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Постановка задачиНеобходимо собрать базовый шаблон RESTful backend приложения на NodeJS + Express, который:
- легко документируется
- просто наполняется функционалом
- позволяет легко настраивать защиту маршрутов
- имеет простую встроенную автоматическую валидацию
Гайд достаточно обширный, поэтому сначала мы разберем и реализуем различные части, а затем соберем приложение воедино. Готовый репозиторий можно посмотреть на Github. Набор инструментовСердце нашего приложения – спецификация OpenApi 3.0. В нашем случае это описание API на языке разметки YAML, которое позволит автоматически генерировать и защищать маршруты и документировать API. Для простоты возьмем MongoDB и mongoose, в целом ничего не помешает заменить эту связку на любую другую в своём шаблоне.
Passport.js – защита маршрутов, аутентификация и авторизация. Стратегия passport-jwt. Мы будем использовать jwt-access и refresh токены.Первоначальная настройкаИнициализируем проект, запустив npm init или yarn init, я предпочитаю yarn.Для начала стоит позаботиться об удобности разработки, стиле кода и допущениях.
За стиль кода у меня отвечают eslint и prettier. В корне создаем конфиги для eslint и prettier. Для удобства разработки и сборки я использую nodemon, npm-run-all, rimraf, babel. Ниже мои настройки:.eslintrc.json
{
"env": {
"node": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"airbnb-base",
"prettier"
],
"plugins": [
"prettier"
],
"rules": {
"no-console": 0,
"prettier/prettier": ["error"],
"import/extensions": 0,
"import/prefer-default-export": "off",
"import/no-unresolved": 0,
"no-duplicate-imports": ["error", { "includeExports": true }],
"react/prop-types": 0,
"no-underscore-dangle": 0,
"no-param-reassign": ["error", { "props": false }],
"no-case-declarations": 0,
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"space-infix-ops": ["error", { "int32Hint": false }],
"no-unused-vars": ["error", { "argsIgnorePattern": "next" }]
}
}
.prettierrc
{
"printWidth": 100,
"singleQuote": true,
"tabWidth": 4,
"bracketSpacing": true,
"endOfLine": "lf",
"semi": true,
"trailingComma": "none"
}
Добавьте в свой package.json
"dependencies": {
"@babel/node": "^7.13.13",
"body-parser": "^1.19.0",
"connect": "^3.7.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-openapi-validator": "^4.12.6",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.12.2",
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"swagger-routes-express": "^3.3.0",
"swagger-ui-express": "^4.1.6",
"uuid": "^8.3.2",
"validator": "^13.5.2",
"yamljs": "^0.3.0"
},
"devDependencies": {
"@babel/cli": "^7.13.14",
"@babel/core": "^7.13.14",
"@babel/preset-env": "^7.13.12",
"eslint": "^7.23.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-prettier": "^3.3.1",
"nodemon": "^2.0.7",
"npm-run-all": "^4.1.5",
"prettier": "^2.2.1",
"rimraf": "^3.0.2"
},
"babel": {
"presets": [
"@babel/preset-env"
]
},
"scripts": {
"transpile": "babel ./src --out-dir bin --copy-files",
"clean": "rimraf bin",
"build": "npm-run-all clean transpile",
"server": "node ./bin/app.js",
"dev": "npm-run-all build server",
"start": "yarn dev",
"watch": "nodemon"
}
Создайте nodemon.json в корне
{
"watch": ["src/*"],
"ext": "js, json, yaml",
"exec": "yarn run dev"
}
Установите зависимости, запустив npm или yarn.Немного про безопасностьЯ подготовил несколько диаграмм, чтобы пошагово разобрать подход, который мы реализуем. На всякий случай собрал их в PDF.Логика такая:
- При успешной аутентификации клиент получает JWT access токен и защищённый http-only куки с refresh токеном.
- При каждом запросе клиент проверяет, не истек ли срок жизни токена доступа (JWT access token), если все ок, вставляет его в заголовок «Authorization», если токен истек, то сначала запрашивает новый токен доступа. Ниже более подробно про наш бэкенд.
Регистрация пользователя
- На Backend передаем в открытом виде(но только по HTTPS) e-mail, пароль, какие-то дополнительные данные, которые вам нужны (nickname для примера на диаграмме)
- Генерируем уникальную соль и хэшируем пароль с этой солью, после чего записываем в базу
Аутентификация
Полная версия изображения
- Клиент передает по HTTPS email и пароль
- Пытаемся получить пользователя из базы
- Получаем либо пользователя, либо undefined
- Если undefined возвращаем сообщение об ошибке, и не говорим неверна почта или пароль
- Берем из базы соль пользователя, хэшируем с этой солью введенный пользователем пароль и сверяем с сохраненным в базе хэшем. Если пароль введен неверно, возвращаем сообщение об ошибке, аналогично пункту 4
- Если пароль введен верно, генерируем JWT токен доступа, с коротким сроком жизни и Refresh токен, который нужен для получения нового токена доступа, с более длительным сроком жизни. Это не показано на диаграмме, но refresh токен записывается в базу как одно из полей пользователя.
- Возвращаем пользователю JWT токен доступа и устанавливаем HTTP-only cookie (secure, т.к. у нас HTTPS).
Обновление JWT токена доступа
Полная версия изображения
- Обращаемся на маршрут обновления токена доступа
- Получаем из HTTP-only cookie refresh токен
- Если refresh токена нет – возвращаем ошибку
- Если токен есть, то проверяем его валидность. Если токен не валидный, возвращаем ошибку.
- Ищем пользователя по refresh токену.
- Если пользователь не найден, то считаем токен более не валидным и возвращаем ошибку.
- Если пользователь найден, то генерируем новую пару JWT токена доступа и refresh токена. Записываем refresh в базу
- И как в предыдущем разделе передаем токен доступа и записываем refresh токен в cookie
Выход из системы
Полная версия изображения
- Переход на маршрут выхода из системы. Внимание на диаграмме ошибка, маршрут что-то типа /user/logout
- Получаем из HTTP-only cookie refresh токен
- Если refresh токена нет – возвращаем ошибку
- Если токен есть, то проверяем его валидность. Если токен не валидный, возвращаем ошибку.
- Ищем пользователя по refresh токену.
- Если пользователь не найден, то считаем токен более не валидным и возвращаем ошибку.
- Удаляем у пользователя из базы refresh токен
- Сбрасываем cookie у клиента
Вспомогательные модули безопасностиСоздайте следующую файловую структуру в корне проекта:
Для работы необходимо подготовить:
- SSL сертификат и закрытый ключ к нему
- Закрытый и публичный ключи для генерации JWT токена доступа
- Закрытый и публичный ключи для генерации JWT refresh токена. На самом деле для реализации refresh токена достаточно генерации уникальной строки, можно использовать uuid, например, но я не ищу легких путей.
Если у вас нет SSL сертификата, можно сгенерировать свой, но использовать такой сертификат в боевом проекте не стоит, так как к self-signed сертификатам нет доверия.Итак для генерации SSL сертификата и закрытого ключа можно воспользоваться openssl:
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout ssl.key -out ssl.crt
Генерируем ключи для JWT:
ssh-keygen -t rsa -b 4096 -m PEM -f jwtPrivate.key
openssl rsa -in jwtPrivate.key -pubout -outform PEM -out jwtPublic.pem
ssh-keygen -t rsa -b 4096 -m PEM -f refreshPrivate.key
openssl rsa -in refreshPrivate.key -pubout -outform PEM -out refreshPublic.pem
Все ключи и сертификаты складываем в ./src/crypto/Напишем несколько вспомогательных модулей:./src/utils/cryptoHelper.js
import crypto from 'crypto';
/**
* Валидация пароля
*/
export function validatePassword(password, hash, salt) {
const hashCandidate = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
return hash === hashCandidate;
}
/**
* Генерация соли и хэша пароля
*/
export function genHashWithSalt(password) {
const salt = crypto.randomBytes(32).toString('hex');
const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
return {
salt,
hash
};
}
./src/utils/jwtHelper.js
import fs from 'fs';
import path from 'path';
import jsonwebtoken from 'jsonwebtoken';
// настраиваем пути и читаем ключи
const jwtPrivate = path.join(__dirname, '../crypto/', 'jwtPrivate.pem');
const refreshPrivate = path.join(__dirname, '../crypto/', 'refreshPrivate.pem');
const refreshPublic = path.join(__dirname, '../crypto/', 'refreshPublic.pem');
const JWT_PRIV_KEY = fs.readFileSync(jwtPrivate, 'utf8');
const REFRESH_PRIV_KEY = fs.readFileSync(refreshPrivate, 'utf8');
const REFRESH_PUBLIC_KEY = fs.readFileSync(refreshPublic, 'utf8');
// выпуск JWT токена доступа
export function issueJWT(user) {
const { _id } = user;
const expiresIn = '10m';
const payload = {
uid: _id,
iat: Math.floor(Date.now() / 1000)
};
const signedToken = jsonwebtoken.sign(payload, JWT_PRIV_KEY, { expiresIn, algorithm: 'RS256' });
return {
token: `Bearer ${signedToken}`,
expires: expiresIn
};
}
//выпуск JWT refresh токена
export function issueRefresh(user) {
const { _id } = user;
const expiresIn = '7d';
const payload = {
uid: _id,
iat: Math.floor(Date.now() / 1000)
};
const signedToken = jsonwebtoken.sign(payload, REFRESH_PRIV_KEY, {
expiresIn,
algorithm: 'RS256'
});
return {
token: signedToken,
expires: expiresIn
};
}
//валидация refresh токена
export function isValidRefresh(token) {
try {
jsonwebtoken.verify(token, REFRESH_PUBLIC_KEY, { algorithm: 'RS256' });
} catch (error) {
return false;
}
return true;
}
./src/utils/passport.js
import { Strategy, ExtractJwt } from 'passport-jwt';
import fs from 'fs';
import path from 'path';
import mongoose from 'mongoose';
import { userSchema } from '../db/models/User';
const User = mongoose.model('User', userSchema);
const pathToKey = path.join(__dirname, '../crypto/', 'jwtPublic.pem');
const PUB_KEY = fs.readFileSync(pathToKey, 'utf8');
const options = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: PUB_KEY,
algorithms: ['RS256']
};
export const strategy = (pass) => {
pass.use(
new Strategy(options, (jwtPayload, done) => {
User.findOne({ _id: jwtPayload.uid }, (err, user) => {
if (err) {
return done(err, false);
}
if (user) {
return done(null, user);
}
return done(null, null);
});
})
);
};
Это описание jwt стратегии, слизано из официальной документации, с небольшими изменениями. Одно из главных - использование публичного ключа, для получения информации из токена../src/utils/securityMiddleware.jsЭто промежуточная функция будет использоваться для защиты наших маршрутов
export const securityMiddleware = (req, res, next, passport, groups) => {
passport.authenticate('jwt', { session: false }, (err, user) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).send('Unauthorized');
}
// добавляем в req поле user с определенным набором полей, отдавать здесь хэш, соль не надо.
const { _id, email, nickname, group } = user;
req.user = {
_id,
email,
nickname,
group
};
if (groups.includes(user.group)) {
return next();
}
return res.status(403).send('Insufficient access rights');
})(req, res, next);
};
Описание APIНаш минимальный API опишет процесс регистрации, аутентификации и авторизации, а также тестовые маршруты для проверки работы разных групп пользователей и открытых разделов.Этот файл будет использоваться валидатором запросов, генератором документации и генератором маршрутов.Обратите внимание на поля operationId – это имена функций-контроллеров, которые мы реализуем и они будут вызываться, чтобы обработать эндпоинты../src/api/apiV1.yaml
openapi: 3.0.3
info:
title: Passport test
description: Test of passport.js
version: 1.0.0
license:
name: MIT License
url: https://opensource.org/licenses/MIT
paths:
# Тестовый публичный маршрут
/test/ping:
get:
description: 'Returns pong'
tags:
- Test
operationId: ping
responses:
'200':
description: OK
$ref: '#/components/responses/standardResponse'
# Тестовый маршрут, для зарегистрированных пользователей
/test/private:
get:
description: 'Testing private section'
tags:
- Test
operationId: testPrivate
security:
- access: ['free']
responses:
'200':
$ref: '#/components/responses/standardResponse'
# Тестовый маршрут, для платных подписчиков
/test/subscription:
get:
description: 'Testing subscribers section'
tags:
- Test
operationId: testSubscription
security:
- access: ['subscriber']
responses:
'200':
$ref: '#/components/responses/standardResponse'
# Маршрут для регистрации пользователей
# обратите внимание на поле email, валидатор будет ожидать формат email
/user/register:
post:
description: 'Register user'
tags:
- User
operationId: userRegister
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
format: email
nickname:
type: string
password:
type: string
format: password
responses:
'200':
description: OK
$ref: '#/components/responses/standardResponse'
'400':
description: Bad Request
$ref: '#/components/responses/standardResponse'
# Маршрут для аутентификации
/user/login:
post:
description: 'Login user'
tags:
- User
operationId: userLogin
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
email:
type: string
format: email
password:
type: string
format: password
responses:
'200':
description: Returns boolean success state and jwt object
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
jwt:
type: object
'400':
description: Login failed
$ref: '#/components/responses/standardResponse'
/user/refresh:
get:
description: 'Refresh token'
tags:
- User
operationId: userRefreshToken
responses:
'200':
description: 'Token refreshed'
$ref: '#/components/responses/jwtResponse'
'403':
description: 'Token refresh error'
$ref: '#/components/responses/standardResponse'
/user/logout:
get:
description: 'Logout user. Remove cookie. Remove refresh token in DB'
tags:
- User
operationId: userLogout
responses:
'200':
description: 'Successfully logged out'
$ref: '#/components/responses/standardResponse'
'403':
description: 'You are not logged in to logout!'
$ref: '#/components/responses/standardResponse'
/user/profile:
get:
description: 'Returns user object'
tags:
- User
operationId: userProfile
security:
- access: [ 'free' ]
responses:
'200':
$ref: '#/components/responses/standardResponse'
'403':
$ref: '#/components/responses/standardResponse'
components:
responses:
standardResponse:
description: Returns boolean success state and string message
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
message:
type: string
jwtResponse:
description: Returns boolean success state and jwt object
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
jwt:
type: object
Модель пользователя и mongooseПроцесс создания базы данных и настройку доступа пользователя к ней, я описывать не буду, процесс максимально доступно описан в официальной документации.В корне проекта создайте файл .env и укажите в нем порт, на котором будет работать ваше приложение, а также параметры подключения к БД..env
PORT = 3007
DB_HOST = localhost
DB_PORT = 27017
DB_NAME = passport
DB_USER = passport
DB_PASS = passport
Настройка подключения к БД./src/db/db.js
import Mongoose from 'mongoose';
export const connect = async () => {
const dbHost = process.env.DB_HOST;
const dbPort = process.env.DB_PORT;
const dbName = process.env.DB_NAME;
const user = process.env.DB_USER;
const pass = process.env.DB_PASS;
const uri = `mongodb://${dbHost}:${dbPort}/${dbName}?authSource=dbWithCredentials`;
await Mongoose.connect(uri, {
authSource: dbName,
user,
pass,
useNewUrlParser: true,
useFindAndModify: true,
useUnifiedTopology: true,
useCreateIndex: true
}).catch((err) => console.error(err));
const db = Mongoose.connection;
db.on('error', () => {
throw new Error('Error connecting database');
});
};
Модель нашего пользователя./src/db/models/User.js
import mongoose from 'mongoose';
export const userSchema = new mongoose.Schema(
{
email: {
type: String,
required: true,
unique: true
},
nickname: {
type: String,
required: true,
unique: true
},
hash: {
type: String,
required: true
},
salt: {
type: String,
required: false
},
refreshToken: {
type: Object
},
group: {
type: String
}
},
{ versionKey: false }
);
Обработка эндпоинтовКонтроллеры имеет смысл группировать по функционалу в один файл, если там много всего, то возможно даже по отдельным директориям. В нашем случае хватит файлов.Контроллер user.js содержит функции, логика работы которых подробно описана в диаграммах в разделе про безопасность. Здесь без особых комментариев, код должен быть вполне понятен../src/api/controllers/user.js
import mongoose from 'mongoose';
import { userSchema } from '../../db/models/User';
import * as cryptoHelper from '../../utils/cryptoHelper';
import * as jwtHelper from '../../utils/jwtHelper';
const User = mongoose.model('User', userSchema);
async function sendAndSetTokens(req, res, user) {
const jwt = jwtHelper.issueJWT(user);
const refresh = jwtHelper.issueRefresh(user);
user.refreshToken = refresh;
await user.save();
res.cookie('refreshToken', refresh, {
secure: true,
httpOnly: true
});
res.status(200).json({
success: true,
jwt: {
token: jwt.token,
expiresIn: jwt.expires
}
});
}
// удаление refresh токена из базы
async function resetRefresh(user) {
user.refreshToken = '';
await user.save();
}
// Сброс куки, путем установки пустого значения и короткого срока жизни
function resetCookie(req, res) {
res.cookie('refreshToken', '', {
maxAge: 1000,
secure: true,
httpOnly: true
});
res.status(200).json({
success: true,
message: 'Successfully logged out'
});
}
export function userRegister(req, res, next) {
const { email, nickname, password } = req.body;
const saltHash = cryptoHelper.genHashWithSalt(password);
const { salt, hash } = saltHash;
const newUser = new User({
email,
nickname,
hash,
salt,
group: 'free'
});
newUser
.save()
.then(() => {
res.status(200).json({ success: true, message: 'User registered' });
})
.catch((err) => {
if (err.code === 11000 || err.code === 11001) {
res.status(409).json({
success: false,
message: `E-mail ${email} already registered, try another or log in.`
});
} else {
res.status(400).json({
success: false,
message: err.message
});
}
});
}
export function userLogin(req, res, next) {
User.findOne({ email: req.body.email })
.then(async (user) => {
if (!user) {
res.status(401).json({
success: false,
message: 'Wrong login or password'
});
}
const isValid = cryptoHelper.validatePassword(req.body.password, user.hash, user.salt);
if (isValid) {
await sendAndSetTokens(req, res, user);
} else {
res.status(401).json({
success: false,
message: 'Wrong login or password'
});
}
})
.catch((err) => next(err));
}
export function userRefreshToken(req, res, next) {
const refreshCandidate = req.cookies.refreshToken;
if (refreshCandidate) {
if (jwtHelper.isValidRefresh(refreshCandidate.token)) {
User.findOne({ refreshToken: refreshCandidate })
.then(async (user) => {
await sendAndSetTokens(req, res, user);
})
.catch(() => {
res.status(403).json({
success: false,
message: 'Invalid Refresh Token!'
});
});
} else {
res.status(403).json({
success: false,
message: 'Invalid Refresh Token!'
});
}
} else {
res.status(401).json({
success: false,
message: 'Refresh Token Empty!'
});
}
}
export function userLogout(req, res, next) {
const refreshCandidate = req.cookies.refreshToken;
if (refreshCandidate) {
if (jwtHelper.isValidRefresh(refreshCandidate.token)) {
User.findOne({ refreshToken: refreshCandidate })
.then(async (user) => {
await resetRefresh(user);
resetCookie(req, res);
})
.catch((err) => next(err));
} else {
res.status(401).json({
success: false,
message: 'You are not logged in to logout!'
});
}
} else {
res.status(401).json({
success: false,
message: 'Refresh Token Empty!!'
});
}
}
export function userProfile(req, res, next) {
if (req.user) {
res.status(200).json(req.user);
}
}
Контроллер test.js – набор простейших функций, для проверки работы авторизации и работы незащищенного маршрута../src/api/controllers/test.js
export function ping(req, res) {
res.json({
success: true,
message: 'Pong'
});
}
export function testSubscription(req, res) {
res.status(200).json({
success: true,
message: 'You are subscriber!'
});
}
export function testPrivate(req, res) {
res.status(200).json({
success: true,
message: 'You are in Private!'
});
}
Осталось экспортировать все это разом в ./src/api/controllers/index.js
export * from './test';
export * from './user';
Собираем все воединоНам осталось собрать все в кучу и написать точку входа. Для этого мы напишем server.js и положим его в ./src/utils и app.js, который положим в ./srcНа этих файлах остановимся подробнее. Начнем с импортов, что для чего нужно:
- express – сам наш сервер
- cookieParser – промежуточное ПО, которое позволит нам работать с куки
- swaggerUI – интерфейс документации, который строится на основании описания API в yaml файле.
- swagger-routes-express – автоматическая генерация маршрутов (линковка эндпоинтов к функциям контроллеров на основании того же yaml файла API)
- yaml – работа с yaml файлами
- express-openapi-validator – простой валидатор запросов (может и ответы валидировать, но я не включал. Включается элементарно изменением значения в true)
- morgan – мощный инструмент логирования, который я использую для вывода информации в консоль, чтобы дебажить в реальном времени.
- cors – установка заголовков CORS, чтобы не делать ручками. Немного подробнее поговорим ниже.
- passport – та самая библиотека, которая упрощает нам работу по защите маршрутов
- дальше подключаем контроллеры, базу, стратегию passport.
Теперь первым делом инициализируем нашу стратегию, передав ей объект passport:
strategy(passport);
Подключаемся к БД:
db.connect()
.then(() => console.log('MongoDB connected'))
.catch((error) => console.error(error));
Загружаем и выводим в консоль информацию по API:
const yamlSpecFile = './bin/api/apiV1.yaml';
const apiDefinition = YAML.load(yamlSpecFile);
const apiSummary = summarise(apiDefinition);
console.info(apiSummary);
Инициализируем инстанс express:
const server = express();
Настройка сервера
// подключаем логирование с помощью morgan
server.use(morgan('dev'));
// позволяем себе читать параметры из url
server.use(express.urlencoded({ extended: true }));
// это промежуточное по позволяет парсить входящие запросы с application/json
server.use(express.json());
// позволяет работать с куки
server.use(cookieParser());
// настройка CORS. В боевом проекте стоит указать адреса, для которых будет доступен наш БЭК
//var corsOptions = {
// origin: 'http://example.com',
// optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
//}
// cors(corsOptions)
// Более подробно смотрите документацию пакета на npmjs.com
server.use(cors());
// инициализируем passport.js
server.use(passport.initialize());
Автоматическая валидация запросов
// Чтобы включить валидацию ответов, поправьте параметр validateResponses
// обратите внимание, что здесь мы указываем yaml файл API
const validatorOptions = {
coerceTypes: false,
apiSpec: yamlSpecFile,
validateRequests: true,
validateResponses: false
};
server.use(OpenApiValidator.middleware(validatorOptions));
// Кастомизация ошибок, если валидация не пройдена
server.use((err, req, res, next) => {
res.status(err.status).json({
error: {
type: 'request_validation',
message: err.message,
errors: err.errors
}
});
});
Самый главный участок – генерация маршрутов и их защита.Коннектору передается объект, в который мы импортировали все функции контроллеров и описание API. На основании этих данных он линкует и создает маршруты, которые в стандартной документации и гайдах выглядят как server.use('/route/to/something', controllerFunction... у нас этого не будет.Также обратите внимание на объект security, объекты subscriber и free, это поля из yaml файла описания api, в разделе security acess. Промежуточному ПО здесь мы передаем стандартный набор для middleware + объект paspport + массив групп, которым разрешен доступ к маршрутам, отмеченным определенным уровнем доступа.
const connect = connector(api, apiDefinition, {
onCreateRoute: (method, descriptor) => {
console.log(
`Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}`
);
},
security: {
subscriber: (req, res, next) => {
securityMiddleware(req, res, next, passport, ['subscriber', 'admin']);
},
free: (req, res, next) => {
securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']);
}
}
});
Осталось обернуть наш сервер коннектором и экспортировать
connect(server);
module.exports = server;
./src/utils/server.js
import express from 'express';
import cookieParser from 'cookie-parser';
import swaggerUi from 'swagger-ui-express';
import { connector, summarise } from 'swagger-routes-express';
import YAML from 'yamljs';
import * as OpenApiValidator from 'express-openapi-validator';
import morgan from 'morgan';
import cors from 'cors';
import passport from 'passport';
import * as api from '../api/controllers';
import * as db from '../db/db';
import { securityMiddleware } from './securityMiddleware';
import { strategy } from './passport';
strategy(passport);
// connect to DB
db.connect()
.then(() => console.log('MongoDB connected'))
.catch((error) => console.error(error));
// load API definition
const yamlSpecFile = './bin/api/apiV1.yaml';
const apiDefinition = YAML.load(yamlSpecFile);
const apiSummary = summarise(apiDefinition);
console.info(apiSummary);
const server = express();
server.use(morgan('dev'));
server.use(express.urlencoded({ extended: true }));
server.use(express.json());
server.use(cookieParser());
server.use(cors());
server.use(passport.initialize());
// API Documentation
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDefinition, { explorer: false }));
// Automatic validation
const validatorOptions = {
coerceTypes: false,
apiSpec: yamlSpecFile,
validateRequests: true,
validateResponses: false
};
server.use(OpenApiValidator.middleware(validatorOptions));
// error customization, if request is invalid
server.use((err, req, res, next) => {
res.status(err.status).json({
error: {
type: 'request_validation',
message: err.message,
errors: err.errors
}
});
});
// Automatic routing based on api definition
const connect = connector(api, apiDefinition, {
onCreateRoute: (method, descriptor) => {
console.log(
`Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}`
);
},
security: {
subscriber: (req, res, next) => {
securityMiddleware(req, res, next, passport, ['subscriber', 'admin']);
},
free: (req, res, next) => {
securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']);
}
}
});
connect(server);
module.exports = server;
Осталась точка входа – app.js. Здесь все достаточно просто, распишу все в комментариях.
import https from 'https';
import fs from 'fs';
import * as dotenv from 'dotenv';
import server from './utils/server';
// помещаем в process.env переменные из .env файла
dotenv.config();
const { PORT } = process.env;
// загружаем сертификат и закрытый ключ
const privateKey = fs.readFileSync('./bin/crypto/ssl.key');
const certificate = fs.readFileSync('./bin/crypto/ssl.crt');
const options = { key: privateKey, cert: certificate };
// создаем HTTPS сервер
const app = https.createServer(options, server);
// запускаем на порту, который указали в .env файле
app.listen(PORT, () => {
console.info(`Listening on https://localhost:${PORT}`);
console.info(`Open https://localhost:${PORT}/api-docs for documentation`);
});
Основная информация взята из этих статей:https://losikov.medium.com/part-2-express-open-api-3-0-634385c97a4ehttps://medium.com/swlh/everything-you-need-to-know-about-the-passport-jwt-passport-js-strategy-8b69f39014b0Спасибо за внимание, надеюсь кому-то этот лонгрид поможет .
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка веб-сайтов, JavaScript, Angular, TypeScript] RxJS Challenge: Неделя 1
- [Python, Программирование, SQL, Микросервисы, Flask] Чтобы первый блин не вышел комом. Советы начинающему разработчику сервиса
- [Python, Программирование] Создание функции губки из MD5 (перевод)
- [Программирование, C] fork() — зло; vfork() — добро; afork() — лучше; clone () — глупо (перевод)
- [Разработка веб-сайтов, JavaScript, ReactJS] 5 приемов по разделению «бандла» и «ленивой» загрузке компонентов в React (перевод)
- [JavaScript, Разработка мобильных приложений, Swift, ReactJS] Как Лёня с React на Swift переезжал
- [Программирование, Машинное обучение] Простой граф знаний на текстовых данных
- [Python, Программирование, Открытые данные, Машинное обучение] Датасет о мобильных приложениях
- [Программирование, Управление разработкой, Лайфхаки для гиков] Фишки IDEA. Часть 2
- [Программирование, SQL, Алгоритмы, ERP-системы] Множественные источники данных в интерфейсе — client-side «SQL»
Теги для поиска: #_javascript, #_programmirovanie (Программирование), #_node.js, #_restful, #_express, #_passport.js, #_jwt, #_node, #_openapi, #_javascript, #_programmirovanie (
Программирование
), #_node.js
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 16-Ноя 09:22
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Постановка задачиНеобходимо собрать базовый шаблон RESTful backend приложения на NodeJS + Express, который:
Passport.js – защита маршрутов, аутентификация и авторизация. Стратегия passport-jwt. Мы будем использовать jwt-access и refresh токены.Первоначальная настройкаИнициализируем проект, запустив npm init или yarn init, я предпочитаю yarn.Для начала стоит позаботиться об удобности разработки, стиле кода и допущениях. За стиль кода у меня отвечают eslint и prettier. В корне создаем конфиги для eslint и prettier. Для удобства разработки и сборки я использую nodemon, npm-run-all, rimraf, babel. Ниже мои настройки:.eslintrc.json {
"env": { "node": true, "es2021": true }, "extends": [ "eslint:recommended", "airbnb-base", "prettier" ], "plugins": [ "prettier" ], "rules": { "no-console": 0, "prettier/prettier": ["error"], "import/extensions": 0, "import/prefer-default-export": "off", "import/no-unresolved": 0, "no-duplicate-imports": ["error", { "includeExports": true }], "react/prop-types": 0, "no-underscore-dangle": 0, "no-param-reassign": ["error", { "props": false }], "no-case-declarations": 0, "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], "space-infix-ops": ["error", { "int32Hint": false }], "no-unused-vars": ["error", { "argsIgnorePattern": "next" }] } } {
"printWidth": 100, "singleQuote": true, "tabWidth": 4, "bracketSpacing": true, "endOfLine": "lf", "semi": true, "trailingComma": "none" } "dependencies": {
"@babel/node": "^7.13.13", "body-parser": "^1.19.0", "connect": "^3.7.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "dotenv": "^8.2.0", "express": "^4.17.1", "express-openapi-validator": "^4.12.6", "jsonwebtoken": "^8.5.1", "mongoose": "^5.12.2", "morgan": "^1.10.0", "passport": "^0.4.1", "passport-jwt": "^4.0.0", "swagger-routes-express": "^3.3.0", "swagger-ui-express": "^4.1.6", "uuid": "^8.3.2", "validator": "^13.5.2", "yamljs": "^0.3.0" }, "devDependencies": { "@babel/cli": "^7.13.14", "@babel/core": "^7.13.14", "@babel/preset-env": "^7.13.12", "eslint": "^7.23.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-prettier": "^8.1.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-prettier": "^3.3.1", "nodemon": "^2.0.7", "npm-run-all": "^4.1.5", "prettier": "^2.2.1", "rimraf": "^3.0.2" }, "babel": { "presets": [ "@babel/preset-env" ] }, "scripts": { "transpile": "babel ./src --out-dir bin --copy-files", "clean": "rimraf bin", "build": "npm-run-all clean transpile", "server": "node ./bin/app.js", "dev": "npm-run-all build server", "start": "yarn dev", "watch": "nodemon" } {
"watch": ["src/*"], "ext": "js, json, yaml", "exec": "yarn run dev" }
Полная версия изображения
Полная версия изображения
Полная версия изображения
Для работы необходимо подготовить:
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout ssl.key -out ssl.crt
ssh-keygen -t rsa -b 4096 -m PEM -f jwtPrivate.key
openssl rsa -in jwtPrivate.key -pubout -outform PEM -out jwtPublic.pem ssh-keygen -t rsa -b 4096 -m PEM -f refreshPrivate.key openssl rsa -in refreshPrivate.key -pubout -outform PEM -out refreshPublic.pem import crypto from 'crypto';
/** * Валидация пароля */ export function validatePassword(password, hash, salt) { const hashCandidate = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex'); return hash === hashCandidate; } /** * Генерация соли и хэша пароля */ export function genHashWithSalt(password) { const salt = crypto.randomBytes(32).toString('hex'); const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex'); return { salt, hash }; } import fs from 'fs';
import path from 'path'; import jsonwebtoken from 'jsonwebtoken'; // настраиваем пути и читаем ключи const jwtPrivate = path.join(__dirname, '../crypto/', 'jwtPrivate.pem'); const refreshPrivate = path.join(__dirname, '../crypto/', 'refreshPrivate.pem'); const refreshPublic = path.join(__dirname, '../crypto/', 'refreshPublic.pem'); const JWT_PRIV_KEY = fs.readFileSync(jwtPrivate, 'utf8'); const REFRESH_PRIV_KEY = fs.readFileSync(refreshPrivate, 'utf8'); const REFRESH_PUBLIC_KEY = fs.readFileSync(refreshPublic, 'utf8'); // выпуск JWT токена доступа export function issueJWT(user) { const { _id } = user; const expiresIn = '10m'; const payload = { uid: _id, iat: Math.floor(Date.now() / 1000) }; const signedToken = jsonwebtoken.sign(payload, JWT_PRIV_KEY, { expiresIn, algorithm: 'RS256' }); return { token: `Bearer ${signedToken}`, expires: expiresIn }; } //выпуск JWT refresh токена export function issueRefresh(user) { const { _id } = user; const expiresIn = '7d'; const payload = { uid: _id, iat: Math.floor(Date.now() / 1000) }; const signedToken = jsonwebtoken.sign(payload, REFRESH_PRIV_KEY, { expiresIn, algorithm: 'RS256' }); return { token: signedToken, expires: expiresIn }; } //валидация refresh токена export function isValidRefresh(token) { try { jsonwebtoken.verify(token, REFRESH_PUBLIC_KEY, { algorithm: 'RS256' }); } catch (error) { return false; } return true; } import { Strategy, ExtractJwt } from 'passport-jwt';
import fs from 'fs'; import path from 'path'; import mongoose from 'mongoose'; import { userSchema } from '../db/models/User'; const User = mongoose.model('User', userSchema); const pathToKey = path.join(__dirname, '../crypto/', 'jwtPublic.pem'); const PUB_KEY = fs.readFileSync(pathToKey, 'utf8'); const options = { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: PUB_KEY, algorithms: ['RS256'] }; export const strategy = (pass) => { pass.use( new Strategy(options, (jwtPayload, done) => { User.findOne({ _id: jwtPayload.uid }, (err, user) => { if (err) { return done(err, false); } if (user) { return done(null, user); } return done(null, null); }); }) ); }; export const securityMiddleware = (req, res, next, passport, groups) => {
passport.authenticate('jwt', { session: false }, (err, user) => { if (err) { return next(err); } if (!user) { return res.status(401).send('Unauthorized'); } // добавляем в req поле user с определенным набором полей, отдавать здесь хэш, соль не надо. const { _id, email, nickname, group } = user; req.user = { _id, email, nickname, group }; if (groups.includes(user.group)) { return next(); } return res.status(403).send('Insufficient access rights'); })(req, res, next); }; openapi: 3.0.3
info: title: Passport test description: Test of passport.js version: 1.0.0 license: name: MIT License url: https://opensource.org/licenses/MIT paths: # Тестовый публичный маршрут /test/ping: get: description: 'Returns pong' tags: - Test operationId: ping responses: '200': description: OK $ref: '#/components/responses/standardResponse' # Тестовый маршрут, для зарегистрированных пользователей /test/private: get: description: 'Testing private section' tags: - Test operationId: testPrivate security: - access: ['free'] responses: '200': $ref: '#/components/responses/standardResponse' # Тестовый маршрут, для платных подписчиков /test/subscription: get: description: 'Testing subscribers section' tags: - Test operationId: testSubscription security: - access: ['subscriber'] responses: '200': $ref: '#/components/responses/standardResponse' # Маршрут для регистрации пользователей # обратите внимание на поле email, валидатор будет ожидать формат email /user/register: post: description: 'Register user' tags: - User operationId: userRegister requestBody: required: true content: application/json: schema: type: object properties: email: type: string format: email nickname: type: string password: type: string format: password responses: '200': description: OK $ref: '#/components/responses/standardResponse' '400': description: Bad Request $ref: '#/components/responses/standardResponse' # Маршрут для аутентификации /user/login: post: description: 'Login user' tags: - User operationId: userLogin requestBody: required: true content: application/json: schema: type: object properties: email: type: string format: email password: type: string format: password responses: '200': description: Returns boolean success state and jwt object content: application/json: schema: type: object properties: success: type: boolean jwt: type: object '400': description: Login failed $ref: '#/components/responses/standardResponse' /user/refresh: get: description: 'Refresh token' tags: - User operationId: userRefreshToken responses: '200': description: 'Token refreshed' $ref: '#/components/responses/jwtResponse' '403': description: 'Token refresh error' $ref: '#/components/responses/standardResponse' /user/logout: get: description: 'Logout user. Remove cookie. Remove refresh token in DB' tags: - User operationId: userLogout responses: '200': description: 'Successfully logged out' $ref: '#/components/responses/standardResponse' '403': description: 'You are not logged in to logout!' $ref: '#/components/responses/standardResponse' /user/profile: get: description: 'Returns user object' tags: - User operationId: userProfile security: - access: [ 'free' ] responses: '200': $ref: '#/components/responses/standardResponse' '403': $ref: '#/components/responses/standardResponse' components: responses: standardResponse: description: Returns boolean success state and string message content: application/json: schema: type: object properties: success: type: boolean message: type: string jwtResponse: description: Returns boolean success state and jwt object content: application/json: schema: type: object properties: success: type: boolean jwt: type: object PORT = 3007
DB_HOST = localhost DB_PORT = 27017 DB_NAME = passport DB_USER = passport DB_PASS = passport import Mongoose from 'mongoose';
export const connect = async () => { const dbHost = process.env.DB_HOST; const dbPort = process.env.DB_PORT; const dbName = process.env.DB_NAME; const user = process.env.DB_USER; const pass = process.env.DB_PASS; const uri = `mongodb://${dbHost}:${dbPort}/${dbName}?authSource=dbWithCredentials`; await Mongoose.connect(uri, { authSource: dbName, user, pass, useNewUrlParser: true, useFindAndModify: true, useUnifiedTopology: true, useCreateIndex: true }).catch((err) => console.error(err)); const db = Mongoose.connection; db.on('error', () => { throw new Error('Error connecting database'); }); }; import mongoose from 'mongoose';
export const userSchema = new mongoose.Schema( { email: { type: String, required: true, unique: true }, nickname: { type: String, required: true, unique: true }, hash: { type: String, required: true }, salt: { type: String, required: false }, refreshToken: { type: Object }, group: { type: String } }, { versionKey: false } ); import mongoose from 'mongoose';
import { userSchema } from '../../db/models/User'; import * as cryptoHelper from '../../utils/cryptoHelper'; import * as jwtHelper from '../../utils/jwtHelper'; const User = mongoose.model('User', userSchema); async function sendAndSetTokens(req, res, user) { const jwt = jwtHelper.issueJWT(user); const refresh = jwtHelper.issueRefresh(user); user.refreshToken = refresh; await user.save(); res.cookie('refreshToken', refresh, { secure: true, httpOnly: true }); res.status(200).json({ success: true, jwt: { token: jwt.token, expiresIn: jwt.expires } }); } // удаление refresh токена из базы async function resetRefresh(user) { user.refreshToken = ''; await user.save(); } // Сброс куки, путем установки пустого значения и короткого срока жизни function resetCookie(req, res) { res.cookie('refreshToken', '', { maxAge: 1000, secure: true, httpOnly: true }); res.status(200).json({ success: true, message: 'Successfully logged out' }); } export function userRegister(req, res, next) { const { email, nickname, password } = req.body; const saltHash = cryptoHelper.genHashWithSalt(password); const { salt, hash } = saltHash; const newUser = new User({ email, nickname, hash, salt, group: 'free' }); newUser .save() .then(() => { res.status(200).json({ success: true, message: 'User registered' }); }) .catch((err) => { if (err.code === 11000 || err.code === 11001) { res.status(409).json({ success: false, message: `E-mail ${email} already registered, try another or log in.` }); } else { res.status(400).json({ success: false, message: err.message }); } }); } export function userLogin(req, res, next) { User.findOne({ email: req.body.email }) .then(async (user) => { if (!user) { res.status(401).json({ success: false, message: 'Wrong login or password' }); } const isValid = cryptoHelper.validatePassword(req.body.password, user.hash, user.salt); if (isValid) { await sendAndSetTokens(req, res, user); } else { res.status(401).json({ success: false, message: 'Wrong login or password' }); } }) .catch((err) => next(err)); } export function userRefreshToken(req, res, next) { const refreshCandidate = req.cookies.refreshToken; if (refreshCandidate) { if (jwtHelper.isValidRefresh(refreshCandidate.token)) { User.findOne({ refreshToken: refreshCandidate }) .then(async (user) => { await sendAndSetTokens(req, res, user); }) .catch(() => { res.status(403).json({ success: false, message: 'Invalid Refresh Token!' }); }); } else { res.status(403).json({ success: false, message: 'Invalid Refresh Token!' }); } } else { res.status(401).json({ success: false, message: 'Refresh Token Empty!' }); } } export function userLogout(req, res, next) { const refreshCandidate = req.cookies.refreshToken; if (refreshCandidate) { if (jwtHelper.isValidRefresh(refreshCandidate.token)) { User.findOne({ refreshToken: refreshCandidate }) .then(async (user) => { await resetRefresh(user); resetCookie(req, res); }) .catch((err) => next(err)); } else { res.status(401).json({ success: false, message: 'You are not logged in to logout!' }); } } else { res.status(401).json({ success: false, message: 'Refresh Token Empty!!' }); } } export function userProfile(req, res, next) { if (req.user) { res.status(200).json(req.user); } } export function ping(req, res) {
res.json({ success: true, message: 'Pong' }); } export function testSubscription(req, res) { res.status(200).json({ success: true, message: 'You are subscriber!' }); } export function testPrivate(req, res) { res.status(200).json({ success: true, message: 'You are in Private!' }); } export * from './test';
export * from './user';
strategy(passport);
db.connect()
.then(() => console.log('MongoDB connected')) .catch((error) => console.error(error)); const yamlSpecFile = './bin/api/apiV1.yaml';
const apiDefinition = YAML.load(yamlSpecFile); const apiSummary = summarise(apiDefinition); console.info(apiSummary); const server = express();
// подключаем логирование с помощью morgan
server.use(morgan('dev')); // позволяем себе читать параметры из url server.use(express.urlencoded({ extended: true })); // это промежуточное по позволяет парсить входящие запросы с application/json server.use(express.json()); // позволяет работать с куки server.use(cookieParser()); // настройка CORS. В боевом проекте стоит указать адреса, для которых будет доступен наш БЭК //var corsOptions = { // origin: 'http://example.com', // optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204 //} // cors(corsOptions) // Более подробно смотрите документацию пакета на npmjs.com server.use(cors()); // инициализируем passport.js server.use(passport.initialize()); // Чтобы включить валидацию ответов, поправьте параметр validateResponses
// обратите внимание, что здесь мы указываем yaml файл API const validatorOptions = { coerceTypes: false, apiSpec: yamlSpecFile, validateRequests: true, validateResponses: false }; server.use(OpenApiValidator.middleware(validatorOptions)); // Кастомизация ошибок, если валидация не пройдена server.use((err, req, res, next) => { res.status(err.status).json({ error: { type: 'request_validation', message: err.message, errors: err.errors } }); }); const connect = connector(api, apiDefinition, {
onCreateRoute: (method, descriptor) => { console.log( `Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}` ); }, security: { subscriber: (req, res, next) => { securityMiddleware(req, res, next, passport, ['subscriber', 'admin']); }, free: (req, res, next) => { securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']); } } }); connect(server);
module.exports = server; import express from 'express';
import cookieParser from 'cookie-parser'; import swaggerUi from 'swagger-ui-express'; import { connector, summarise } from 'swagger-routes-express'; import YAML from 'yamljs'; import * as OpenApiValidator from 'express-openapi-validator'; import morgan from 'morgan'; import cors from 'cors'; import passport from 'passport'; import * as api from '../api/controllers'; import * as db from '../db/db'; import { securityMiddleware } from './securityMiddleware'; import { strategy } from './passport'; strategy(passport); // connect to DB db.connect() .then(() => console.log('MongoDB connected')) .catch((error) => console.error(error)); // load API definition const yamlSpecFile = './bin/api/apiV1.yaml'; const apiDefinition = YAML.load(yamlSpecFile); const apiSummary = summarise(apiDefinition); console.info(apiSummary); const server = express(); server.use(morgan('dev')); server.use(express.urlencoded({ extended: true })); server.use(express.json()); server.use(cookieParser()); server.use(cors()); server.use(passport.initialize()); // API Documentation server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDefinition, { explorer: false })); // Automatic validation const validatorOptions = { coerceTypes: false, apiSpec: yamlSpecFile, validateRequests: true, validateResponses: false }; server.use(OpenApiValidator.middleware(validatorOptions)); // error customization, if request is invalid server.use((err, req, res, next) => { res.status(err.status).json({ error: { type: 'request_validation', message: err.message, errors: err.errors } }); }); // Automatic routing based on api definition const connect = connector(api, apiDefinition, { onCreateRoute: (method, descriptor) => { console.log( `Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}` ); }, security: { subscriber: (req, res, next) => { securityMiddleware(req, res, next, passport, ['subscriber', 'admin']); }, free: (req, res, next) => { securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']); } } }); connect(server); module.exports = server; import https from 'https';
import fs from 'fs'; import * as dotenv from 'dotenv'; import server from './utils/server'; // помещаем в process.env переменные из .env файла dotenv.config(); const { PORT } = process.env; // загружаем сертификат и закрытый ключ const privateKey = fs.readFileSync('./bin/crypto/ssl.key'); const certificate = fs.readFileSync('./bin/crypto/ssl.crt'); const options = { key: privateKey, cert: certificate }; // создаем HTTPS сервер const app = https.createServer(options, server); // запускаем на порту, который указали в .env файле app.listen(PORT, () => { console.info(`Listening on https://localhost:${PORT}`); console.info(`Open https://localhost:${PORT}/api-docs for documentation`); }); =========== Источник: habr.com =========== Похожие новости:
Программирование ), #_node.js |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 16-Ноя 09:22
Часовой пояс: UTC + 5