Добавляем аутентификацию в REST API на Swagger Node
В предыдущих статьях я описал создание RESTful API для коллекции книг на Swagger Node.js и добавление пагинации - возможности разбивать на части большой список книг в ответе сервера. Теперь же попробуем решить следующую проблему: абсолютно любой пользователь API может как просматривать книги, так и добавлять новые, редактировать и удалять их. Я хочу сделать так, чтобы операции добавления, редактирования и удаления книг были доступны только зарегистрированным пользователям. Для этого требуется встроить в API систему аутентификации пользователей.
Общие данные
Определения
Аутентификация - процедура проверки подлинности пользователя, например, путём сравнения введённого им пароля с паролем, сохранённым в базе данных пользователей.
Авторизация - предоставление определённому пользователю прав на выполнение определённых действий.
Т.о. для того, чтобы авторизировать пользователя на выполнение некоторых операций, мы должны его сперва аутентифицировать.
Применяемые стратегии аутентификации
Basic
Пользователь вводит логин и пароль. Введенные данные объединяются в строку вида:
username:password
Данная строка кодируется в Base64:
dXNlcm5hbWU6cGFzc3dvcmQ=
И передается на сервер в заголовке запроса в виде:
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
На сервере строка декодируется, находится профиль пользователя, верифицируется его пароль.
По этой схеме данные пользователя, по сути, передаются в открытом виде и https обязателен к применению.
JSON Web Token (JWT)
Для подтверждения своей личности пользователь должен предоставить специальный JSON Web Token. Этот токен представляет собой строку вида:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOiJKb2huIERvZSJ9.4emg-RQp4p0i9tDfjhhYDdlFEgHYalkQlbminY_0iSc
Эта строка состоит из трех частей, разделенных точками:
1. Заголовок (header) - строка, закодированная в Base64, представляющая собой JSON объект:
{
"alg": "HS256",
"typ": "JWT"
}
Обычно содержит в себе сведения о типе токена и применяемом алгоритме шифрования.
2. Полезная нагрузка (payload) - строка, закодированная в Base64, представляющая собой JSON объект. Содержит в себе некоторую полезную информацию о пользователе, например:
{
"sub": "1234567890",
"name": "John Doe",
"admin": false
}
3. Подпись - строка, вычисленная по указанному в заголовке алгоритму шифрования:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
secret
- секретная фраза, которую знает только сервер, генерирующий токены и проверяющий их.
Получив данный токен, сервер с помощью секретной фразы шифрует по заданному алгоритму заголовок и нагрузку. Затем сверяет подпись, получившуюся при шифровании, и переданную в токене. Если они совпадают, то токен считается валидным и пользователь проходит аутентификацию. Если же в нагрузку токена внесены какие-то изменения, например "admin": true
, то подписи не совпадут и аутентификация будет провалена.
Завладев чужим токеном, злоумышленник получает его права доступа. Поэтому необходимо использовать https
. Также эта стратегия позволяет добавить в токен срок жизни, по истечении которого требуется обновить токен или получить новый.
Проектируем API
В конце файла swagger.yaml
добавим описание наших стратегий аутентификации:
securityDefinitions:
Basic:
type: basic
JWT:
type: apiKey
name: Authorization
in: header
Конечные точки API (операции добавления, редактирования и удаления книг) будут защищены с помощью JWT. Т.е. пользователь, который захочет добавить книгу, сможет это сделать, только предоставив в заголовке запроса токен.
Выдавать токены будем по маршруту GET /login
. Для получения токена пользователь должен ввести свое имя и пароль, т.е. пройти Basic аутентификацию. Соответственно, чтобы пройти ее, пользователь предварительно должен быть зарегестрирован в нашем приложении. Регистрация новых пользователей будет производиться по маршруту POST /signup
.
Добавим в swagger.yaml
файл описания для пользователя и для токена:
User:
properties:
username:
type: string
password:
type: string
format: password
required:
- username
- password
Token:
properties:
user:
type: string
token:
type: string
required:
- user
- token
Опишем маршрут для регистрации новых пользователей POST /signup
:
/signup:
x-swagger-router-controller: auth-controller
post:
operationId: signup
parameters:
- name: user
in: body
description: New User
required: true
schema:
$ref: '#/definitions/User'
responses:
"200":
description: User created
schema:
$ref: "#/definitions/GeneralResponse"
# responses may fall through to errors
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"
Маршрут для получения токена GET /login
:
/login:
x-swagger-router-controller: auth-controller
get:
operationId: login
security:
- Basic: [] # Так указываем применяемую схему!
responses:
"200":
description: Log In
schema:
$ref: "#/definitions/Token"
# responses may fall through to errors
default:
description: Error
schema:
$ref: "#/definitions/ErrorResponse"
И теперь в описаниях операций, которые мы хотим защитить (POST /books
, PUT /books/{id}
и DELETE /books/{id}
), нужно добавить строки:
security:
- JWT: []
С YAML-файлом закончили. Приступаем к написанию логики.
Реализуем функционал аутентификации API
Нам потребуется passport.js, две стратегии аутентификации, passport-http и passport-jwt, а также jsonwebtoken - модуль для работы с JWT токенами:
npm install passport passport-http passport-jwt jsonwebtoken -save
Определим mongoose схему и модель для пользователя в файле api/models/user-model.js
:
var crypto = require('crypto');
var mongoose = require('mongoose');
var Schema = mongoose.Schema;
// User
var User = new Schema({
username: {
type: String,
unique: true,
required: true
},
hashedPassword: {
type: String,
required: true
},
salt: {
type: String,
required: true
},
created: {
type: Date,
default: Date.now
}
});
// Шифруем пароль
User.methods.encryptPassword = function (password) {
return crypto.createHmac('sha1', this.salt).update(password).digest('hex');
};
// Аксессоры для пароля
User.virtual('password')
.set(function (password) {
this._plainPassword = password;
this.salt = crypto.randomBytes(32).toString('base64');
this.hashedPassword = this.encryptPassword(password);
})
.get(function () {
return this._plainPassword;
});
// Проверка пароля
User.methods.checkPassword = function (password) {
return this.encryptPassword(password) === this.hashedPassword;
};
// Проверка на уникальность 'username'
User.path('username').validate(function(value, done) {
this.model('User').count({ username: value }, function(err, count) {
if (err) {
return done(err);
}
done(!count);
});
}, 'username already exists');
var UserModel = mongoose.model('User', User);
module.exports = UserModel;
Пароли пользователей хранить в БД в открытом виде не будем, а будем хранить случайно сгенерированную соль и хэш пароля, полученный с помощью этой соли. При проверке будем сравнивать не сами пароли, а хэш пароля, переданного пользователем, с хэшем, хранимым в БД.
Создадим файл конфигурации config/config.js
, в котором зададим секретную фразу. С ее помощью будут подписываться наши токены. Там же зададим срок жизни токенов:
const config = {
jwt: {
secret: 'my_supper_extra_mega_uber_secret_phrase',
expiresIn: '2 days'
}
};
module.exports = config;
В файле api/auth/passport.js
опишем работу наших стратегий аутентификации:
var passport = require('passport');
var BasicStrategy = require('passport-http').BasicStrategy;
var JwtStrategy = require('passport-jwt').Strategy,
ExtractJwt = require('passport-jwt').ExtractJwt;
var config = require('../../config/config');
var User = require('../models/user-model');
// Basic стратегия
passport.use(new BasicStrategy(
function(username, password, done) {
User.findOne({ username: username }, function (err, user) {
if (err) {
return done(err);
}
if (!user) {
return done(null, false);
}
if (!user.checkPassword(password)) {
return done(null, false);
}
return done(null, user);
});
}
));
// JWT стратегия
var opts = {};
opts.jwtFromRequest = ExtractJwt.fromAuthHeader();
opts.secretOrKey = config.jwt.secret;
passport.use(new JwtStrategy(opts, function(jwt_payload, done) {
User.findOne({username: jwt_payload.username}, function(err, user) {
if (err) {
return done(err);
}
if (!user) {
return done(null, false);
}
return done(null, user);
});
}));
module.exports = passport;
В api/helpers/security-handlers.js
напишем обработчики безопасности, которые будет использовать Swagger:
var passport = require('../auth/passport');
function Basic (req, def, scopes, callback) {
passport.authenticate('basic', {session: false}, function (err, user, info) {
if (err) {
callback(new Error('Authentication error'));
} else if (!user) {
callback(new Error('User not found'));
} else {
req.user = user;
callback();
}
})(req, null, callback);
}
function JWT (req, def, scopes, callback) {
passport.authenticate('jwt', {session: false}, function (err, user, info) {
if (err) {
callback(new Error('Authentication error'));
} else if (!user) {
callback(new Error('User not found'));
} else {
req.user = user;
callback();
}
})(req, null, callback);
}
module.exports = {
Basic: Basic,
JWT: JWT
};
Эти обработчики требуется подключить в конфиге SwaggerExpress в файле app.js
:
var passport = require('./api/auth/passport');
var secHandlers = require ('./api/helpers/security-handlers');
app.use(passport.initialize());
...
var config = {
appRoot: __dirname,
swaggerSecurityHandlers: {
Basic: secHandlers.Basic,
JWT: secHandlers.JWT
}
};
SwaggerExpress.create(config, function (err, swaggerExpress) {
...
Именно их названия мы указывали для операций в поле security
swagger.yaml файла.
Осталось написать контроллеры для регистрации новых пользователей и для получения токена по логину и паролю. В файле api/controllers/auth-controller.js
пропишем:
var jwt = require('jsonwebtoken');
var config = require('../../config/config');
var User = require('../models/user-model');
module.exports = {
signup: signup,
login: login
};
// Регистрация новых пользователей
function signup(req, res) {
var user = new User (req.swagger.params.user.value);
user.save(function (err) {
if (err) {
console.error(err);
res.status(500).json({message: err.message});
} else {
res.json({message: 'OK'});
}
})
}
// Аутентификация пользователей по логину и паролю, выдача токена
function login(req, res) {
if (req.isAuthenticated()) {
res.json({
user: req.user.username,
token: 'JWT ' + jwt.sign({username: req.user.username}, config.jwt.secret, {expiresIn: config.jwt.expiresIn})
});
}
}
Теперь можно проверять работу аутентификации.
Тестируем аутентификацию
Запускаем mongod, выполняем swagger project start
, swagger project edit
. В Swagger Editor зарегистрируем нового пользователя по маршруту POST /signup
.
В правой части редактора вверху появились блоки, которые позволяют аутентифицироваться:
Введем логин и пароль нашего тестового пользователя в Basic форму. Затем по маршруту GET /login
получим токен для нашего пользователя:
Попробуем создать книгу по маршруту POST /books
, который требует наличие JWT токена:
В ответе получим ошибку "User not found".
А теперь аутентифицируемся по JWT, вставив в поле key
токен, который мы получили ранее.
И вновь попробуем создать книгу:
Аутентификация пройдена, новая книга успешно добавлена.
Ну как-то так. Весь код на Github.