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