Сегодня мы создадим безопасную серверную часть аутентификации, используя передовые методы обеспечения безопасности TRIDE и TRUE, такие как веб-токены JSON (JWT), обновление JWT, хеширование паролей, проверку ввода, очистку ввода, электронную почту SMTP и проверку электронной почты.
Начать проект
Установите Node.JS
Убедитесь, что у вас установлен Node.JS.
Инициируйте свой проект
Создайте каталог проекта, я назову его secure-express-auth.
Затем инициируйте создание файла package.json с помощью:
npm init
Выполните настройку package.json.
Это создаст package.json, который содержит конфигурацию и сведения о вашем проекте.
Установить Экспресс
Убедитесь, что у вас установлен Экспресс.
Установить экспресс:
npm install --save express
Установите пакет ведения журнала в зависимости от разработчиков: morgan
Теперь создайте файл с именем app.js в каталоге верхнего уровня и вставьте следующий код:
const express = require('express'); const logger = require('morgan'); // Create an express app const app = express(); // Setup middleware // Use a logger for dev purposes app.use(logger("dev")); // Set the port const port = 3000; app.listen(port, () => { console.log(`Server is listening on port ${port}`); })
Теперь мы должны иметь возможность запускать сервер в терминале через:
node app.js
Большой! Теперь, когда у нас есть это сообщение, мы знаем, что наше приложение по крайней мере запускается. Теперь давайте попробуем открыть соединение с нашим приложением в браузере:
Как видите, мы получаем сообщение об ошибке, говорящее о том, что мы не можем сделать запрос GET. Это связано с тем, что в настоящее время у нас нет настроенных маршрутов. Мы решим эту проблему за несколько минут!
Настройка MongoDB
MongoDB — это программное обеспечение базы данных, ориентированное на работу с документами, без SQL.
- Создайте учетную запись MongoDB.
- Создайте организацию MongoDB.
- Создайте проект MongoDB.
- Создайте бесплатный общий кластер.
- Создайте имя пользователя и пароль.
- Установите роль пользователя на администратора Atlas.
- Создайте БД.
Подключение к вашей базе данных
Сначала установите mongoose, плагин, созданный для облегчения нашей жизни при работе с любой MongoDB.
npm install mongoose
Получите строку подключения из MongoDB и обязательно замените все переменные в скобках соответствующими учетными данными.
Это должно выглядеть примерно так:
mongodb+srv://<username>:<password>@<cluster-name>.<cluster-id>.mongodb.net/<database-name>?retryWrites=true&w=majority
Пример: приведенная выше строка подключения после замены переменных предоставленными учетными данными.
mongodb+srv://admin:[email protected]/test?retryWrites=true&w=majority
Теперь создайте новую папку с именем middleware, а затем создайте в ней файл с именем db.js.
const mongoose = require('mongoose'); // This function is used to connect to the given MongoDB URI module.exports = async function connectDB(mongoUri) { try { const conn = await mongoose.connect(mongoUri, { useNewUrlParser: true, useUnifiedTopology: true }); console.log(`MongoDB Connected: ${conn.connection.host}`) } catch (err) { console.error(err); process.exit(1); } }
Теперь, требуя db.js и вызывая функцию connectDB в app.js:
const express = require('express'); const logger = require('morgan'); // Create an express app const app = express(); // Setup middleware // Use a logger for dev purposes app.use(logger("dev")); // Connect to the database const connectDB = require('./middleware/db'); const mongoUri = "mongodb+srv://admin-brandon:[email protected]/test?retryWrites=true&w=majority"; connectDB(mongoUri); // Set the port const port = 3000; app.listen(port, () => { console.log(`Server is listening on port ${port}`); })
Мы должны увидеть сообщение об успешном подключении.
Настройка пользовательской модели/схемы
Схемы позволяют создавать структуру данных, которая представляет собой модель.
Создайте новую папку с именем models в каталоге верхнего уровня, а затем внутри models создайте файл с именем user.js и вставьте следующее :
const {Schema, model} = require('mongoose'); const userSchema = new Schema({ name: { type: String, required: true }, email: { type: String, required: true, unique: true }, password: { type: String, required: true }, joined: { type: Date, default: Date.now }, lastLoggedIn: { type: Date, default: Date.now } }); module.exports = User = model("User", userSchema);
Маршруты и контроллеры
Здесь начинается знакомство с маршрутами и контроллерами. Маршруты – это способ настроить расположение на сервере, доступное тем или иным способом с помощью HTTP-метода (GET, POST, PUT, PATCH и т. д.). Контроллер — это часть маршрута, который обычно принимает запрос и ответ в качестве параметров и предоставляет место для размещения вашей логики для обработки запроса и возврата некоторой важной информации. исходя из маршрута.
Создайте папку routes и папку controller в каталоге верхнего уровня.
Настройка Регистрация
Создайте маршрут регистрации
В папке routes создайте файл с именем auth.js:
const express = require('express'); const router = express.Router(); const {register} = require('../controllers/auth'); router.post("/register", register);
Настройте маршрут аутентификации в приложении в файле app.js.
const express = require('express'); const logger = require('morgan'); // Create an express app const app = express(); // Setup middleware // Use a logger for dev purposes app.use(logger("dev")); // Use this middleware for configuring json requests app.use(express.json({limit: '1kb', extended: false})); // Setup routes const auth = require('./routes/auth'); app.use('/v1/auth', auth); // Connect to the database const connectDB = require('./db'); const mongoUri = "mongodb+srv://admin-brandon:[email protected]/test?retryWrites=true&w=majority"; connectDB(mongoUri); // Set the port const port = 3000; app.listen(port, () => { console.log(`Server is listening on port ${port}`); })
Это добавит все маршруты, экспортированные в наш маршрутизатор авторизации, по URL-адресу:
http://локальный: 3000/v1/аутентификация
Таким образом, URL-адрес нашего регистрационного маршрутизатора будет следующим:
http://localhost:3000/v1/auth/register
И, как вы можете видеть, в настоящее время мы испытываем ошибку. Это просто устанавливает маршрут для посещения пользователем; он пока не включает в себя функциональность/логику для предоставления ответа, и именно здесь в игру вступают контроллеры.
Создайте контроллер регистрации
В папке controllers создайте файл с именем auth.js и вставьте следующий код:
const User = require("../models/User"); exports.register = async (req, res) => { try { // Destructure the request body const {name, email, password, confirmPassword} = req.body; // Check if the user already exists let user = await User.findOne({email: email}); // If the user already exists, return an error if (user) { return res.status(400).json({errors: [{message: "User already exists"}]}); } // Check if the passwords match if (password !== confirmPassword) { return res.status(400).json({errors: [{message: "Passwords do not match"}]}); } // Create and save a new user user = new User({ name: name, email: email, password: password, joined: Date.now(), lastLoggedIn: Date.now(), }); await user.save(); user.password = undefined; // Return a success message return res.status(200).json({message: "Registration successful", data: user}); } catch (err) { console.error(err.message); return res.status(500).send({message: "Server error"}); } }
Вход в систему
Обновите routes/auth.js, чтобы включить маршрут входа.
const express = require('express'); const router = express.Router(); const {register, login} = require('../controllers/auth'); router.post("/register", register); router.post("/login", login);
Создайте контроллер входа в систему
В нашей папке controllers обновите файл с именем auth.js, указав следующий код:
const User = require("../models/User"); exports.register = async (req, res) => { try { // Destructure the request body const {name, email, password, confirmPassword} = req.body; // Check if the user already exists let user = await User.findOne({email: email}); // If the user already exists, return an error if (user) { return res.status(400).json({errors: [{message: "User already exists"}]}); } // Check if the passwords match if (password !== confirmPassword) { return res.status(400).json({errors: [{message: "Passwords do not match"}]}); } // Create and save a new user user = new User({ name: name, email: email, password: password, joined: Date.now(), lastLoggedIn: Date.now(), }); await user.save(); // Return a success message return res.status(200).json({message: "Registration successful", user: user}); } catch (err) { console.error(err.message); return res.status(500).send({message: "Server error"}); } } exports.login = async (req, res) => { try { // Destructure the request body const {email, password} = req.body; // Check if the user exists let user = await User.findOne({email: email}); // If the user does not exist, return an error if (!user) { return res.status(400).json({errors: [{message: "Invalid email/password combination"}]}); } // Check if the password is correct if (password !== user.password) { return res.status(400).json({errors: [{message: "Invalid email/password combination"}]}); } // Update and save the lastLoggedIn field user.lastLoggedIn = Date.now(); await user.save(); user.password = undefined; // Return a success message return res.status(200).json({message: "Login successful", user: user}); } catch (err) { console.error(err.message); return res.status(500).send({message: "Server error"}); } }
Ура, теперь, когда все работает... Подождите, разве я не упоминал, что это приложение было безопасным? На текущей итерации это все, НО безопасно! Ниже мы подробно рассмотрим реализацию безопасности в вашем новом API аутентификации пользователей.
Безопасность
Как вы, возможно, уже заметили, в нашем приложении сильно не хватает безопасности, кроме наших ручных проверок. Безопасность чрезвычайно важна для любого веб-сервиса, особенно для тех, которые имеют дело с конфиденциальными данными, такими как любые пользовательские данные.
Переменные среды
Переменные среды обычно состоят из конфигурации проекта, ключей API, имен пользователей и паролей для служб и т. д. В краткой форме переменные среды обычно хранят любые конфиденциальные учетные данные и конфигурацию, необходимые для вашего сервера.
mkdir config cd config touch .env MONGO_URI=mongodb+srv://<username>:<password>@<cluster-name>.<cluster-id>.mongodb.net/<database-name>?retryWrites=true&w=majority PORT=3000 const express = require('express'); const {xss} = require('express-xss-sanitizer'); const bodyParser = require('body-parser'); const logger = require('morgan'); const dotenv = require('dotenv'); // Load environment variables dotenv.config({path: './config/.env'}); // Create an express app const app = express(); // Use a logger for dev purposes app.use(logger("dev")); // Setup middleware app.use(express.json({limit: '1kb', extended: false)); // Setup routes const authRouter = require('./routes/auth'); app.use("/v1/auth", authRouter); // Connect to the database const connectDB = require('./middleware/db'); connectDB(process.env.MONGO_URI); // Set the port app.listen(process.env.PORT, () => { console.log(`Server is listening on port ${process.env.PORT}`); })
Проверка ввода
Учитывая вышеизложенное, нам необходимо проверить входные данные, которые мы получаем из тела запроса, чтобы мы могли убедиться, что получаемые нами данные соответствуют структуре созданных нами моделей. Это проверка ввода.
Сначала установите пакет joi.
npm install --save joi
Затем мы создадим папку с именем валидаторы.
Затем мы создадим файл с именем auth.js.
const joi = require('joi'); module.exports = registerValidator = (data) => { const schema = joi.object({ name: joi.string().required(), email: joi.string().email().required(), password: joi.string().min(6).required(), confirmPassword: joi.ref('password'), }); return schema.validate(data); } module.exports = loginValidator = (data) => { const schema = joi.object({ email: joi.string().email().required(), password: joi.string().min(6).required(), }); return schema.validate(data); }
Вызовите эти функции в соответствующих контроллерах.
const User = require("../models/User"); // @route POST api/auth/register // @desc Register a user // @access Public exports.register = async (req, res) => { try { // Validate the request body const data = registerValidator(req.body); // Check if the user already exists let user = await User.findOne({email: data.email}); // If the user already exists, return an error if (user) { return res.status(400).json({errors: [{message: "User already exists"}]}); } // Create and save a new user user = new User(data); await user.save(); user.password = undefined; // Return a success message return res.status(200).json({message: "Registration successful", user: user}); } catch (err) { console.error(err.message); return res.status(500).send({message: "Server error"}); } } // @route POST api/auth/login // @desc Login a user // @access Public exports.login = async (req, res) => { try { // Validate the input const data = loginValidator(req.body); if (!data) { return res.status(401).send({errors // Check if the user exists let user = await User.findOne({email: data.email}); // If the user does not exist, return an error if (!user) { return res.status(400).json({message: "Invalid email/password combination"}); } // Check if the password is correct if (password !== user.password) { return res.status(400).json({errors: [{message: "Invalid email/password combination"}]}); } // Update and save the lastLoggedIn field user.lastLoggedIn = Date.now(); await user.save(); // Return a success message return res.status(200).json({message: "Login successful", user: user}); } catch (err) { console.error(err.message); return res.status(500).send({message: "Server error"}); } }
Очистка ввода
Очистка входных данных — еще одна важная практика безопасности.
Предотвращение SQL-инъекций и межсайтовых сценариев (xss).
npm i --save express-xss-sanitizer
Затем мы либо передаем xss в качестве промежуточного программного обеспечения для определенных маршрутов, на которых он нам нужен, либо мы передаем его приложению в целом.
const express = require('express'); const logger = require('morgan'); // Create an express app const app = express(); // Setup middleware // Use a logger for dev purposes app.use(logger("dev")); // Use this middleware for configuring json requests app.use(express.json({limit: '1kb', extended: false})); // Setup input sanitization app.use(xss()); // Setup routes const auth = require('./routes/auth'); app.use('/v1/auth', auth); // Connect to the database const connectDB = require('./db'); const mongoUri = "mongodb+srv://admin-brandon:[email protected]/test?retryWrites=true&w=majority"; connectDB(mongoUri); // Set the port const port = 3000; app.listen(port, () => { console.log(`Server is listening on port ${port}`); })
Хэширование пароля
Это большой. В экспрессе самый популярный проект для хэширования пароля — через bcrypt.
npm i --save bcrypt
Теперь обновите файл controllers/auth.js.
контроллеры/auth.js
const User = require("../models/user"); const {validator} = require("../validators/auth"); const bcrypt = require("bcrypt"); // @route POST api/auth/register // @desc Register a user // @access Public exports.register = async (req, res) => { try { // Validate the request body const valid = validator.register(req.body); if (!valid) { return res.status(400).send({message: valid.error.map(err => err.message)}); } let data = req.body; // Check if the user already exists let user = await User.findOne({email: data.email}); // If the user already exists, return an error if (user) { return res.status(400).send({message: "User already exists"}); } // Check if the passwords match if (data.password !== data.confirmPassword) { return res.status(400).send({message: "Passwords do not match"}); } else { delete data.confirmPassword; data.password = bcrypt.hashSync(data.password, 10); } // Create and save a new user user = new User(data); await user.save(); // Return a success message return res.status(200).send({message: "Registration successful", user: user}); } catch (err) { console.log(err.message); return res.status(500).send({message: err.toString()}); } } // @route POST api/auth/login // @desc Login a user // @access Public exports.login = async (req, res) => { try { // Validate the request body const valid = validator.login(req.body); if (!valid) { return res.status(400).send({message: valid.error.map(err => err.message)}); } let data = req.body; // Check if the user exists let user = await User.findOne({email: req.body.email}); // If the user does not exist, return an error if (!user) { return res.status(400).send({message: "Invalid email/password combination"}); } // Check if the password is correct if (await bcrypt.compare(user.password, data.password)) { console.log(data.password, user.password); return res.status(400).send({message: "Invalid email/password combination"}); } // Update and save the lastLoggedIn field user.lastLoggedIn = Date.now(); await user.save(); // Return a success message return res.status(200).send({message: "Login successful", user: user}); } catch (err) { console.error(err.message); return res.status(500).send({message: "Server error"}); } }
Веб-токены JSON (JWT)
JWT великолепны. Они проверяют, что машина, делающая запрос, ДЕЙСТВИТЕЛЬНО является проверенным пользователем. Как правило, JWT реализуются как таковые:
У этих токенов обычно есть дата истечения срока действия, при этом дата истечения срока действия обычного токена наступает до даты истечения срока действия токена обновления.
Для успешного входа в систему мы генерируем токен и токен обновления, срок действия которых соответствует вышеуказанным требованиям. Затем мы отправляем эти токены обратно пользователю, где он будет хранить их безопасно и локально. Теперь, когда пользователь отправляет запрос к одной из наших конечных точек (которая использует промежуточное ПО), у нас будет промежуточное ПО JWT для проверки того, что запрос действительно выполняется с допустимым JWT.
Если срок действия обычного токена пользователя истек, а токена обновления нет. Тогда обычная проверка токена завершится неудачно, и токен обновления пройдет. Затем оба токена сбрасываются, добавляются к полезной нагрузке ответа, а затем запрос обрабатывается как обычно.
Однако, если срок действия обоих токенов пользователя истек, запрос будет отклонен из-за отсутствия авторизации, и пользователю потребуется снова войти в систему, чтобы успешно выполнить запрос.
Начните с установки express-jwt:
npm i - save express-jwt
Во-первых, давайте добавим в наш .env секретные ключи для токенов (они могут быть любой строкой, просто сделайте их уникальными):
MONGO_URI=mongodb+srv://admin-brandon:[email protected]/test?retryWrites=true&w=majority PORT=3000 ACCESS_TOKEN_SECRET=abcdefghijklm REFRESH_TOKEN_SECRET=nopqrstuvwxyz
Затем мы обновим наш контроллер входа в controllers/auth.js:
const User = require("../models/user"); const {validator} = require("../validators/auth"); const bcrypt = require("bcrypt"); const jwt = require("jsonwebtoken"); // @route POST api/auth/register // @desc Register a user // @access Public exports.register = async (req, res) => { try { // Validate the request body const valid = validator.register(req.body); if (!valid) { return res.status(400).send({ message: valid.error.map(err => err.message) }); } let data = req.body; // Check if the user already exists let user = await User.findOne({ email: data.email }); // If the user already exists, return an error if (user) { return res.status(400).send({ message: "User already exists" }); } // Check if the passwords match if (data.password !== data.confirmPassword) { return res.status(400).send({ message: "Passwords do not match" }); } else { delete data.confirmPassword; data.password = bcrypt.hashSync(data.password, 10); } // Create and save a new user user = new User(data); await user.save(); user.password = undefined; // Return a success message return res.status(200).send({ message: "Registration successful", user: user }); } catch (err) { console.log(err.message); return res.status(500).send({ message: err.toString() }); } } // @route POST api/auth/login // @desc Login a user // @access Public exports.login = async (req, res) => { try { // Validate the request body const valid = validator.login(req.body); if (!valid) { return res.status(400).send({ message: valid.error.map(err => err.message) }); } let data = req.body; // Check if the user exists let user = await User.findOne({ email: req.body.email }); // If the user does not exist, return an error if (!user) { return res.status(400).send({ message: "Invalid email/password combination" }); } // Check if the password is correct if (!await bcrypt.compare(data.password, user.password)) { return res.status(400).send({ message: "Invalid email/password combination" }); } // Sign new tokens const accessToken = jwt.sign({ _id: user._id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: "15m", }); const refreshToken = jwt.sign({ _id: user._id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: "90d", }); await res.header("access-token", accessToken); await res.header("refresh-token", refreshToken); // Update and save the lastLoggedIn field user.lastLoggedIn = Date.now(); await user.save(); user.password = undefined; // Return a success message return res.status(200).send({ message: "Login successful", user: user }); } catch (err) { console.error(err.message); return res.status(500).send({ message: "Server error" }); } }
Теперь создайте промежуточное ПО jwt в файле middleware/jwt.js.
const jwt = require("jsonwebtoken"); const { TokenExpiredError, JsonWebTokenError } = jwt; module.exports = verifyTokens = async (req, res, next) => { // Grab the tokens from the header const accessToken = req.header("access-token"); const refreshToken = req.header("refresh-token"); // Make sure the user added the tokens to the request payload if (!accessToken || !refreshToken) { return res .status(401) .send({ message: "Unauthorized. Access token is required." }); } try { // Verify the auth token const authVerified = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET); // If the auth token is valid, pass the request to the next middleware if (authVerified) { console.log("Token verified for user: " + authVerified._id); req.user = authVerified; await res.header("access-token", accessToken); await res.header("refresh-token", refreshToken); next(); } } catch (error) { // If the auth token is expired, try to refresh it return this.jwtMiddleware.catchError(jwt.decode(accessToken)["_id"], error, req, res, next); } }; module.exports = catchError = async (_id, err, req, res, next) => { if (err instanceof TokenExpiredError) { // Verify refresh token try { let refreshToken = req.header("refresh-token"); const refreshVerified = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET); if (refreshVerified) { const accessToken = jwt.sign({ _id: _id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: "15m", }); refreshToken = jwt.sign({ _id: _id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: "90d", }); await res.header("access-token", accessToken); await res.header("refresh-token", refreshToken); next(); } } catch (err) { return res.status(401).send({ message: "Invalid refresh token" }); } } else if (err instanceof JsonWebTokenError) { console.log(err.stack); return res.status(401).send({ message: "Invalid token" }); } else { return res.status(500).send({ message: "Unauthorized" }); } };
Наконец, добавьте это промежуточное ПО к своим частным маршрутам. Или, другими словами, любые маршруты, для которых требуется авторизация. На данный момент в этом проекте нет никого, кто мог бы это проверить. Давайте создадим маршрутизатор для некоторых методов чтения и обновления для конкретного пользователя.
проверки/user.js
const Joi = require("joi"); module.exports.validator = { update: data => { const schema = Joi.object({ name: Joi.string().optional(), }); return schema.validate(data) }, }
контроллеры/user.js
const User = require('../models/user'); const {validator} = require('../validators/user'); // @route GET api/user/:id exports.getUser = async (req, res) => { try { const id = req.params.id; if (!id) { return res.status(400).send({message: "Invalid user id"}); } // Check if the user exists let user = await User.findById({_id: id}); if (!user) { return res.status(404).send({message: "User not found"}); } // Remove hashed password from the response user.password = undefined; return res.status(200).send({message: "User found", user: user}); } catch (err) { console.log(err.message); return res.status(500).send({message: err.toString()}); } } exports.updateUser = async (req, res) => { try { // Get the user id from the request params const id = req.params.id; // Check if the given id is the same as the id in the access token if (id !== req.user.id) { return res.status(401).send({message: "Unauthorized"}); } // Get the request body let data = req.body; // Add id to the data object for validation of the id data.id = id; // Validate the request body const valid = validator.update(data); if (!valid) { return res.status(400).send({message: valid.error.map(err => err.message)}); } // Check if the user exists let user = await User.findById({_id: id}); if (!user) { return res.status(404).send({message: "User not found"}); } // Update the user user.set(data); await user.save(); user.password = undefined; return res.status(200).send({message: "User successfully updated", user: user}); } catch (err) { console.log(err.message); return res.status(500).send({message: err.toString()}); } }
маршруты/user.js
const express = require('express'); const router = express.Router(); const { getUser, updateUser } = require('../controllers/user'); const { jwtMiddleware } = require('../middleware/jwt'); // @route GET api/auth // @desc Get user profile // @access Public router.get("/:id", getUser); // @route PATCH api/auth // @desc Update user // @access Private router.patch("/:id", verifyTokens, updateUser); module.exports = router;
app.js
const express = require('express'); const { xss } = require('express-xss-sanitizer'); const logger = require('morgan'); const dotenv = require('dotenv'); // Load environment variables dotenv.config({ path: './config/.env' }); // Create an express app const app = express(); // Setup middleware app.use(xss()); app.use(logger("dev")); app.use(express.json({ limit: '1kb', extended: false })); // Setup routes const authRouter = require('./routes/auth'); app.use("/v1/auth", authRouter); const userRouter = require('./routes/user'); app.use("/v1/user", userRouter); // Connect to the database const connectDB = require('./middleware/db'); connectDB(process.env.MONGO_URI); // Set the port app.listen(process.env.PORT, () => { console.log(`Server is listening on port ${process.env.PORT}`); })