Last major update: 21.10.2019
Аутентификация(authentication, от греч. αὐθεντικός [authentikos] – реальный, подлинный; от αὐθέντης [authentes] – автор) - это процесс проверки учётных данных пользователя (логин/пароль). Проверка подлинности пользователя путём сравнения введённого им логина/пароля с данными сохранёнными в базе данных.
Авторизация(authorization — разрешение, уполномочивание) - это проверка прав пользователя на доступ к определенным ресурсам.
Например после аутентификации юзер sasha получает право обращатся и получать от ресурса "super.com/vip" некие данные. Во время обращения юзера sasha к ресурсу vip система авторизации проверит имеет ли право юзер обращатся к этому ресурсу (проще говоря переходить по неким разрешенным ссылкам)
- Юзер c емайлом sasha_gmail.com успешно прошел аутентификацию
- Сервер посмотрел в БД какая роль у юзера
- Сервер сгенерил юзеру токен с указанной ролью
- Юзер заходит на некий ресурс используя полученный токен
- Сервер смотрит на права(роль) юзера в токене и соотвественно пропускает или отсекает запрос
Собственно п.5 и есть процесс авторизации.
Дабы не путатся с понятиями Authentication/Authorization можно использовать псевдонимы checkPassword/checkAccess(я так сделал в своей API)
JSON Web Token (JWT) — содержит три блока, разделенных точками: заголовок(header), набор полей (payload) и сигнатуру. Первые два блока представлены в JSON-формате и дополнительно закодированы в формат base64. Набор полей содержит произвольные пары имя/значения, притом стандарт JWT определяет несколько зарезервированных имен (iss, aud, exp и другие). Сигнатура может генерироваться при помощи и симметричных алгоритмов шифрования, и асимметричных. Кроме того, существует отдельный стандарт, отписывающий формат зашифрованного JWT-токена.
Пример подписанного JWT токена (после декодирования 1 и 2 блоков):
{ alg: "HS256", typ: "JWT" }.{ iss: "auth.myservice.com", aud: "myservice.com", exp: 1435937883, userName: "John Smith", userRole: "Admin" }.S9Zs/8/uEGGTVVtLggFTizCsMtwOJnRhjaQ2BMUQhcY
Токены предоставляют собой средство авторизации для каждого запроса от клиента к серверу. Токены(и соотвественно сигнатура токена) генерируются на сервере основываясь на секретном ключе(который хранится на сервере) и payload'e. Токен в итоге хранится на клиенте и используется при необходимости авторизации како-го либо запроса. Такое решение отлично подходит при разработке SPA.
При попытке хакером подменить данные в header'ре или payload'е, токен cтанет не валидным, поскольку сигнатура не будет соответствовать изначальным значениям. А возможность сгенерировать новую сигнатуру у хакера отсутствует, поскольку секретный ключ для зашифровки лежит на сервере.
access token - используется для авторизации запросов и хранения дополнительной информации о пользователе (аля user_id, user_role или еще что либо, эту информацию также называет payload). Сам токен храним не в localStorage как это обычно делают, а в памяти клиентского приложения.
refresh token - выдается сервером по результам успешной аутентификации и используется для получения новой пары access/refresh токенов. Храним в любом персистентном хранилище.
Каждый токен имеет свой срок жизни, например access: 30 мин, refresh: 60 дней
Поскольку токены это не зашифрованная информация крайне не рекомендуется хранить в них какую либо sensitive data
(passwords, payment credentials, etc...)
Роль рефреш токенов и зачем их хранить в БД. Рефреш на сервере хранится для учета доступа и инвалидации краденых токенов. Таким образом сервер наверняка знает о клиентах которым стоит доверять(кому позволено авторизоваться). Если не хранить рефреш токен в БД то велика вероятность того что токены будут бесконтрольно гулять по рукам злоумышленников. Для отслеживания которых нам прийдется заводить черный список и периодически чистить его от просроченных. В место этого мы храним лимитированный список белых токенов для каждого юзера отдельно и в случае кражи у нас уже есть механизм противодействия(описано ниже).
- Пользователь логинится в приложении, передавая логин/пароль и fingerprint браузера (ну или некий иной уникальный индентификатор устройства если это не браузер)
- Сервер проверят подлинность логина/пароля,
- В случае удачи создает и записывает сессию в БД
{ userId: uuid, refreshToken: uuid, expiresIn: int, fingerprint: string, ... }
- Отправляет клиенту два токена access и refresh token uuid (взятый из выше созданной сессии)
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "9f34dd3a-ff8d-43aa-b286-9f22555319f6"
- Клиент сохраняет токены(access в памяти приложения, refresh персистентно), используя access token для последующей авторизации запросов.
Перед каждым запросом клиент предварительно проверяет время жизни access token'а (да берем expires_in
прямо из JWT в клиентском приложении) и если оно истекло использует refresh token чтобы обновить ОБА токена и продолжает использовать новый access token. Для большей уверенности можем обновлять токены на несколько секунд раньше.
Что такое fingerprint ? Это инструмент отслеживания браузера вне зависимости от желания пользователя быть идентифицированным. Это хеш сгенерированный js'ом на базе неких уникальных параметров/компонентов браузера. Преимущество fingerprint'a в том что он нигде персистентно не хранится и генерируется только в момент логина и рефреша.
- Библиотека для хеширования: https://github.com/Valve/fingerprintjs2
- Более подробно: https://player.vimeo.com/video/151208427
- Пример ф-ции получения такого хеша: https://gist.github.com/zmts/b26ba9a61aa0b93126fc6979e7338ca3
Для использования возможности аутентификации на более чем одном девайсе необходимо хранить все рефреш токены по каждому юзеру. Я храню это список в PostgreSQL таблице. В процессе каждого логина создается запись с IP/Fingerprint и другой мета информацией то есть сессия.
CREATE TABLE sessions (
"id" SERIAL PRIMARY KEY,
"userId" uuid REFERENCES users(id) ON DELETE CASCADE,
"refreshToken" uuid NOT NULL,
"ua" character varying(200) NOT NULL, /* user-agent */
"fingerprint" character varying(200) NOT NULL,
"ip" character varying(15) NOT NULL,
"expiresIn" bigint NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now()
);
- Клиент(фронтенд) проверяет перед запросом не истекло ли время жизни access token'на
- Если истекло клиент отправляет на
auth/refresh-token
{ refreshToken: uuid, fingerprint: string }
- Сервер получает запись сессии по UUID'у рефреш токена
- Cохраняет текущую сессию в переменную и удаляет ее из таблицы
- Проверяет текущую сессию: 5.1 Не истекло ли время жизни 5.1 На соответствие старого fingerprint'a полученного из текущей сессии с новым полученным из тела запроса
- В случае негативного результата бросает ошибку
TOKEN_EXPIRED
/INVALID_SESSION
- В случае успеха создает новую сессию и записывает ее в БД
- Создает новый access token
- Отправляет клиенту
{ accessToken, refreshToken }
Стоит заметить что процесс добавления сессии в таблицу должен имеет свои меры безопасности. При добавлении стоит проверять сколько сессий всего есть у юзера и если их слишком много или юзер конектится одновременно из нескольких подсетей, стоит предпринять меры. Имплементируя данную проверку я проверяю только что бы юзер имел максимум до 5 одновременных сессий максимум, и на 6'ой удаляю все остальные сессии кроме текущей(6'ой). Все остальные проверки на ваше усмотрение в зависимости от задачи.
Таким образом если юзер залогинился на пяти устройствах, рефреш токены будут постоянно обновляться и все счастливы. Но если с аккаунтом юзера начнут производить подозрительные действия(попытаются залогинится более чем на 5'ти устройствах) система сбросит все сессии(рефреш токены) кроме последней.
В момент рефреша то есть обновления access token'a обновляются ОБА токена. Но как же refresh token может сам себя обновить, он ведь создается только после успешной аунтефикации ? refresh token в момент рефреша сравнивает себя с тем refresh token'ом который лежит в БД и вслучае успеха, а также если у него не истек срок, система рефрешит токены.
Вопрос зачем refresh token'y срок жизни, если он обновляется каждый раз при обновлении access token'a ? Это сделано на случай если юзер будет в офлайне более 60 дней, тогда прийдется заново вбить логин/пароль.
- Хакер воспользовался access token'ом
- Закончилось время жизни access token'на
- Клиент хакера отправляет refresh token и fingerprint (если знает что он нужен)
- Сервер смотрит fingerprint хакера (если он есть в запросе)
- Сервер не находит fingerprint хакера в сессии и удаляет ее из БД
- Сервер логирует попытку несанкционированного обновления токенов
- Сервер перенаправляет хакера на станицу логина. Хакер идет лесом
- Юзер пробует зайти на сервер >> обнаруживается что refresh token отсутствует
- Сервер перенаправляет юзера на форму аутентификации
- Юзер вводит логин/пароль
Front-end:
- https://github.com/zmts/beauty-vuejs-boilerplate/blob/master/src/services/http.init.js
- https://github.com/zmts/beauty-vuejs-boilerplate/blob/master/src/services/auth.service.js
Back-end:
- Заметка базируется на: https://habrahabr.ru/company/Voximplant/blog/323160/
- https://tools.ietf.org/html/rfc6749
- https://www.digitalocean.com/community/tutorials/oauth-2-ru
- https://jwt.io/introduction/
- https://auth0.com/blog/using-json-web-tokens-as-api-keys/
- https://auth0.com/blog/cookies-vs-tokens-definitive-guide/
- https://auth0.com/blog/ten-things-you-should-know-about-tokens-and-cookies/
- https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/
- https://habr.com/company/dataart/blog/262817/
- https://habr.com/post/340146/
- https://habr.com/company/mailru/blog/115163/
- https://scotch.io/tutorials/authenticate-a-node-js-api-with-json-web-tokens
- https://egghead.io/courses/json-web-token-jwt-authentication-with-node-js
- https://www.digitalocean.com/community/tutorials/oauth-2-ru
- https://github.com/shieldfy/API-Security-Checklist/blob/master/README-ru.md
- https://www.youtube.com/watch?v=Ngh3KZcGNaU
- https://www.youtube.com/playlist?list=PLvTBThJr861y60LQrUGpJNPu3Nt2EeQsP
- https://www.youtube.com/watch?v=R0-eoLp871s
- https://www.youtube.com/watch?v=u9hn3s2kUrg
- http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/
- http://cryto.net/~joepie91/blog/2016/06/19/stop-using-jwt-for-sessions-part-2-why-your-solution-doesnt-work/
- https://medium.com/@cjainn/anatomy-of-a-jwt-token-part-1-8f7616113c14
- https://medium.com/@cjainn/anatomy-of-a-jwt-token-part-2-c12888abc1a2
- https://scotch.io/bar-talk/why-jwts-suck-as-session-tokens
- https://t.me/why_jwt_is_bad