JSON Web Token (JWT) – токен, который содержит минимально необходимую информацию для аутентификации и авторизации. В зашифрованном виде выглядит, как строка, которая состоит из трех частей:
- Header (заголовок). Содержит тип токена (в данном случае это JWT), и какой используется алгоритм шифрования.
- Payload (нагрузка). Содержит всевозможные данные (например, информацию о пользователе или время, через которое токен будет не действителен).
- Signature (подпись). Нужна для проверки, что токен не подделан и выдан именно вашим сервисом (проверка валидности токена).
В сравнении с обычным токеном, JWT самодостаточен (self-contained), потому что содержит минимально необходимую информацию о пользователе. Благодаря этому не нужны повторные обращения к базе данных. Обычный же токен – рандомная строка, и валидность этого токена проверяется через запросы к базе данных.
JWT компактен. Его легко можно передать через URL или HTTP header.
Процесс аутентификации через JSON Web Token
Шаг 1. Пользователь вводит данные (имя пользователя, пароль, т.д.) и логинится в системе.
Шаг 2. Сервер создает JWT, в котором будет зашифрована информация о текущем пользователе.
Шаг 3. Сервер возвращает созданный токен пользователю. Этот токен нужно сохранить на локальном компьютере (в куках или local storage), иначе придется постоянно логиниться и получать токен по-новому.
Шаг 4. При запросе к серверу, пользователь отправляет JWT вместе с запросом через Authorization header:
Authorization: Bearer <token>
Шаг 5. Сервер проверяет валидность токена и получает информацию о пользователе.
Шаг 6. Сервер отправляет ответ пользователю.
Аутентификация на примере
Напишем приложение с аутентификацией пользователя используя Django (backend) и Elm (frontend). Django будет предоставлять API для получения токена. Чтобы получить токен, необходимо отправить запрос с именем пользователя и паролем к API. Если данные пользователя введены корректно, то Django отправит в ответ сгенерированный JWT. Конечный результат на картинке:
Исходный код приложения: https://github.com/apirobot/django-elm-auth-with-jwt
Backend с Django
Писать свой велосипед не обязательно, уже есть хорошая библиотека django-rest-framework-jwt для Django, в которой реализована аутентификация с помощью JWT.
Для начала создаем и активируем виртуальное окружение:
$ virtualenv -p python3 venv $ source venv/bin/activate
Теперь устанавливаем зависимости:
$ pip install django djangorestframework djangorestframework-jwt
Создаем Django проект:
$ django-admin startproject jwtme $ mv jwtme backend
Обновляем настройки проекта:
# backend/jwtme/settings.py INSTALLED_APPS = [ ... 'rest_framework', ] REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', ), }
Добавляем URL, по-которому будем отправлять POST запрос с данными пользователя (имя пользователя, пароль) и в ответе получать JWT при успешной аутентификации:
# backend/jwtme/urls.py from rest_framework_jwt.views import obtain_jwt_token urlpatterns = [ url(r'^api/v1/token/obtain/', obtain_jwt_token), ... ]
По умолчанию, токен протухает каждые 5 минут. Если нужно, можно изменить время протухания в настройках:
# backend/jwtme/settings.py import datetime JWT_AUTH = { 'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1) }
Все доступные настройки для django-rest-framework-jwt в документации.
Чтобы в дальнейшем, при обращении к API через Elm, не возникла ошибка, как на скриншоте ниже, нужно добавить специальные response headers на сервере. Благодаря им можно выполнять кросс-доменные запросы.
Устанавливаем django-cors-headers:
$ pip install django-cors-headers
Обновляем настройки проекта:
# backend/jwtme/settings.py INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ ... 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', ... ] CORS_ORIGIN_ALLOW_ALL = True
Итак, запрос к серверу на получение токена и его ответ выглядит так (для запроса использовал HTTPie):
Frontend с Elm
Создаем папку:
$ mkdir frontend && cd frontend
Устанавливаем зависимости:
$ elm-package install elm-lang/html $ elm-package install elm-lang/http
Так как весь исходный код проекта будем хранить в папке src, то нужно явно указать это в файле frontend/elm-package.json:
{ "source-directories": [ "src" ] }
Создаем HTML и добавляем bulma.css стили, чтобы не писать свои:
<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>JWT Me</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.5.0/css/bulma.min.css"> </head> <body> <div id="main"></div> <script src="elm.js"></script> <script> var app = Elm.Main.fullscreen(); </script> </body> </html>
Создаем новый тип (union type) для токена:
-- frontend/src/Data/Token.elm module Data.Token exposing (..) type Token = Token String -- Распаковываем Token и получаем строку tokenToString : Token -> String tokenToString (Token token) = token
Создаем каркас приложения:
-- frontend/src/Main.elm module Main exposing (..) import Html exposing (Html, div, text) import Data.Token as Token exposing (Token) main : Program Never Model Msg main = Html.program { init = init , view = view , update = update , subscriptions = \_ -> Sub.none } -- MODEL type alias Model = { token : Maybe Token } initialModel : Model initialModel = { token = Nothing } init : ( Model, Cmd Msg ) init = ( initialModel, Cmd.none ) -- MESSAGES type Msg = NoOp -- UPDATE update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of NoOp -> ( model, Cmd.none ) -- VIEW view : Model -> Html Msg view model = case model.token of Nothing -> Html.text "Token: none" Just token -> Html.text <| "Token: " ++ ( Token.tokenToString token )
Для запуска приложения будем использовать elm-live. Установите его, если еще этого не сделали, либо используйте другой способ.
Запускаем приложение:
$ elm-live --port=3000 --output=elm.js src/Main.elm --pushstate --open --debug
Напишем декодер для токена, который будет декодить JSON в Elm. Он понадобиться после получения ответа (response) с сервера после успешной аутентификации.
-- frontend/src/Data/Token.elm ... import Json.Decode as Decode exposing (Decoder) decoder : Decoder Token decoder = Decode.map Token Decode.string
Проверяем работу декодера с помощью elm-repl:
> import Data.Token > import Json.Decode > Json.Decode.decodeString Data.Token.decoder "\"123.fds.OSf\"" Ok (Token "123.fds.OSf") : Result.Result String Data.Token.Token
Напишем функцию, которая создает POST запрос (request) для получения токена:
-- frontend/src/Request/Token.elm module Request.Token exposing (obtain) import Json.Decode as Decode import Json.Encode as Encode import Http import Data.Token as Token exposing (Token) apiUrl : String -> String apiUrl str = "http://localhost:8000/api/v1" ++ str obtain : { r | username : String, password : String } -> Http.Request Token obtain { username, password } = let body = Http.jsonBody <| Encode.object [ ("username", Encode.string username) , ("password", Encode.string password) ] -- Декодим JSON ответ с сервера ( {"token": "blah.blah.blah"} ) -- в Elm ( Token "blah.blah.blah" ) decoder = Decode.field "token" Token.decoder in Http.post (apiUrl "/token/obtain/") body decoder
Создадим компонент Login, в котором будем рендериться форма с авторизацией. В этой форме пользователь вводит username и password. После нажатия кнопки Submit выполняется запрос на получение токена к API (/api/v1/token/obtain/). После успешного получения токена, отправляем сообщение с полученным токеном для файла Main.elm:
-- frontend/src/Components/Login.elm module Components.Login exposing ( Model , initialModel , Msg , ExternalMsg(..) , update , view ) import Html exposing (..) import Html.Attributes exposing (class, placeholder, type_) import Html.Events exposing (onInput, onSubmit) import Http import Data.Token as Token exposing (Token) import Request.Token -- MODEL type alias Model = { username : String , password : String } initialModel : Model initialModel = { username = "" , password = "" } -- MESSAGES type Msg = SubmitForm | SetUsername String | SetPassword String -- TokenObtained обрабатывает полученный ответ (response) с сервера. -- Если произошла ошибка, то получаем Http.Error. -- Если все прошло успешно, то получаем Token. | TokenObtained (Result Http.Error Token) {- ExternalMsg - сообщение для главного файла (src/Main.elm) Так как функцию `Login.update` мы будем вызывать в файле Main.elm, то там нам нужно знать, получили ли мы токен с сервера или еще нет. Сообщение `SetToken Token` как раз для этого. Этим сообщением мы передаем токен в файл Main.elm, а затем добавляем его там в Model. Если все еще не понятно, то после того, как мы закончим с кодом, все должно стать на свои места. -} type ExternalMsg = NoOp | SetToken Token -- UPDATE update : Msg -> Model -> ( Model, Cmd Msg, ExternalMsg ) update msg model = case msg of -- Отправляет запрос к серверу на получение токена SubmitForm -> ( model , Http.send TokenObtained (Request.Token.obtain model) , NoOp ) SetUsername username -> ( { model | username = username }, Cmd.none, NoOp ) SetPassword password -> ( { model | password = password }, Cmd.none, NoOp ) -- Токен успешно получен после запроса к серверу TokenObtained (Ok token) -> ( model , Cmd.none , SetToken token -- Сообщение, в котором мы передаем token для Main.elm ) -- Возникла ошибка при получении токена TokenObtained (Err err) -> ( model, Cmd.none, NoOp ) -- VIEW view : Model -> Html Msg view model = section [ class "section" ] [ div [ class "container" ] [ div [ class "columns" ] [ div [ class "column is-one-third" ] [] , div [ class "column is-one-third" ] [ div [] [ h1 [ class "title has-text-centered" ] [ text "Log in" ] , viewSignInForm ] ] , div [ class "column is-one-third" ] [] ] ] ] -- Форма с input для username, input для password и кнопкой Submit viewSignInForm : Html Msg viewSignInForm = Html.form [ onSubmit SubmitForm ] [ div [ class "field" ] [ label [ class "label" ] [ text "Username" ] , div [ class "control has-icons-left has-icons-right" ] [ input [ onInput SetUsername , class "input" , placeholder "Username" , type_ "text" ] [] , span [ class "icon is-small is-left" ] [ i [ class "fa fa-user" ] [] ] ] ] , div [ class "field" ] [ label [ class "label" ] [ text "Password" ] , div [ class "control has-icons-left has-icons-right" ] [ input [ onInput SetPassword , class "input" , placeholder "Password" , type_ "password" ] [] , span [ class "icon is-small is-left" ] [ i [ class "fa fa-lock" ] [] ] ] ] , div [ class "control" ] [ button [ class "button is-primary" ] [ text "Submit" ] ] ]
Подключаем компонент Login в src/Main.elm:
-- frontend/src/Main.elm ... import Components.Login as Login -- MODEL type alias Model = ... , loginModel : Login.Model } initialModel : Model initialModel = ... , loginModel = Login.initialModel } -- MESSAGES type Msg ... | LoginMsg Login.Msg -- UPDATE update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of ... LoginMsg subMsg -> let ( newLoginModel, cmdFromLogin, msgFromLogin ) = Login.update subMsg model.loginModel newModel = -- Обрабатываем сообщение, полученное в `Components/Login.elm` case msgFromLogin of Login.NoOp -> model Login.SetToken token -> { model | token = Just token } in ( { newModel | loginModel = newLoginModel } , Cmd.map LoginMsg cmdFromLogin ) -- VIEW view : Model -> Html Msg view model = case model.token of -- Если токен отсутствует, то показываем форму с авторизацией Nothing -> Login.view model.loginModel |> Html.map LoginMsg ...
Изменим view для случая, когда токен уже получен. Кроме этого добавим новое сообщение Logout:
-- frontend/src/Main.elm ... import Html exposing (Html, section, div, button, text) import Html.Attributes exposing (class, style) import Html.Events exposing (onClick) type Msg ... | Logout update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of ... Logout -> ( initialModel, Cmd.none ) view : Model -> Html Msg view model = case model.token of Nothing -> ... Just token -> section [ class "section" ] [ div [ class "container" ] [ div [ class "columns" ] [ div [ class "column is-one-third" ] [] , div [ class "column is-one-third" ] [ div [] [ div [ class "notification is-success", style [("word-wrap", "break-word")] ] [ text ( "You have been successfully logged in. Your token: " ++ (Token.tokenToString token) ) ] , button [ onClick Logout, class "button is-primary" ] [ text "Logout" ] ] ] , div [ class "column is-one-third" ] [] ] ] ]
Приложение теперь должно работать. Мы можем заполнить форму и получить токен при успешной аутентификации. Проблема в том, что после обновления страницы токен пропадает. Исправим это, сохранив токен в localstorage с помощью Javasript.
Для “общения” с Javascript через Elm создаем порт:
-- frontend/src/Ports.elm port module Ports exposing (storeToken) port storeToken : Maybe String -> Cmd msg
Напишем Javascript код, в котором будем добавлять токен в localstorage при вызове порта storeToken:
<!-- frontend/src/index.html --> <body> ... <script> var app = Elm.Main.fullscreen(localStorage.token || null); app.ports.storeToken.subscribe(function(token) { localStorage.token = token; }); </script> </body>
Добавим функцию, которая преобразует токен в строку, а затем отправит эту строку к Javascript:
-- frontend/src/Data/Token.elm ... import Ports encode : Token -> Value encode (Token token) = Encode.string token storeToken : Token -> Cmd msg storeToken token = encode token |> Encode.encode 0 |> Just -- Преобразуем String в Maybe String |> Ports.storeToken -- Отправляем Maybe String к Javascript через порт
Смысла в одном сохранении нет. Кроме сохранения токена в localstorage, нужно его еще от туда как-то получить после обновления страницы. Это уже обратное “общение”. Из Javascript в Elm. Добавим функцию, которая декодит JSON в Token:
-- frontend/src/Data/Token.elm ... decodeTokenFromStore : Value -> Maybe Token decodeTokenFromStore json = json |> Decode.decodeValue Decode.string -- Декодим json и получаем Result String String |> Result.toMaybe -- Преобразуем Result String String в Maybe String |> Maybe.andThen (Decode.decodeString decoder >> Result.toMaybe) -- Пропускаем String через Token.decoder и получаем Maybe Token
Теперь сохраняем токен в localstorage. Для этого обновляем случай с успешным получением токена в компоненте Login:
-- frontend/src/Components/Login.elm ... update : Msg -> Model -> ( Model, Cmd Msg, ExternalMsg ) update msg model = case msg of ... TokenObtained (Ok token) -> ( model , Token.storeToken token , SetToken token )
Получаем токен из localstorage при инициализации в Main.elm. Для этого используем флаги (flags) и функцию Html.programWithFlags. Флагами называют данные, которые передаются из Javascript в Elm.
-- frontend/src/Main.elm ... import Json.Encode as Encode exposing (Value) main : Program Value Model Msg main = Html.programWithFlags { init = init , view = view , update = update , subscriptions = \_ -> Sub.none } initialModel : Maybe Token -> Model initialModel token = { token = token , loginModel = Login.initialModel } init : Value -> ( Model, Cmd Msg ) init value = ( initialModel (Token.decodeTokenFromStore value), Cmd.none )
Последний штрих. Обновляем сообщение Logout, в котором удаляем токен из Model и localstorage:
-- frontend/src/Main.elm ... import Ports update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of ... Logout -> ( initialModel Nothing, Ports.storeToken Nothing )