Добавляем аутентификацию в 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 токена:

Error User not found

В ответе получим ошибку "User not found".

А теперь аутентифицируемся по JWT, вставив в поле key токен, который мы получили ранее.

Вводим JWT

И вновь попробуем создать книгу:

Успешная аутентификация

Аутентификация пройдена, новая книга успешно добавлена.

Ну как-то так. Весь код на Github.

Павел Прудников

Постигающий дзен фулстэк вэб буддизма

Минск, Беларусь

Подписаться на Блог MEAN stack разработчика

Получайте свежие записи прямо на ваш почтовый ящик.

Или подпишитесь на RSS с Feedly!

Комментарии

comments powered by Disqus