Home

apirobot

28 Aug 2017

Django + Elm. Аутентификация через JSON Web Token

JSON Web Token (JWT) - токен, который содержит минимально необходимую информацию для аутентификации и авторизации. В зашифрованном виде выглядит, как строка, которая состоит из трех частей:

JWT

  1. Header (заголовок). Содержит тип токена (в данном случае это JWT), и какой используется алгоритм шифрования.
  2. Payload (нагрузка). Содержит всевозможные данные (например, информацию о пользователе или время, через которое токен будет не действителен).
  3. Signature (подпись). Нужна для проверки, что токен не подделан и выдан именно вашим сервисом (проверка валидности токена).

В сравнении с обычным токеном, JWT самодостаточен (self-contained), потому что содержит минимально необходимую информацию о пользователе. Благодаря этому не нужны повторные обращения к базе данных. Обычный же токен - рандомная строка, и валидность этого токена проверяется через запросы к базе данных.

JWT компактен. Его легко можно передать через URL или HTTP header.

Процесс аутентификации через JSON Web Token

JWT auth process

Шаг 1. Пользователь вводит данные (имя пользователя, пароль, т.д.) и логинится в системе.
Шаг 2. Сервер создает JWT, в котором будет зашифрована информация о текущем пользователе.
Шаг 3. Сервер возвращает созданный токен пользователю. Этот токен нужно сохранить на локальном компьютере (в куках или local storage), иначе придется постоянно логиниться и получать токен по-новому.
Шаг 4. При запросе к серверу, пользователь отправляет JWT вместе с запросом через Authorization header:

Authorization: Bearer <token>

Шаг 5. Сервер проверяет валидность токена и получает информацию о пользователе.
Шаг 6. Сервер отправляет ответ пользователю.

Аутентификация на примере

Напишем приложение с аутентификацией пользователя используя Django (backend) и Elm (frontend). Django будет предоставлять API для получения токена. Чтобы получить токен, необходимо отправить запрос с именем пользователя и паролем к API. Если данные пользователя введены корректно, то Django отправит в ответ сгенерированный JWT. Конечный результат на картинке:

Result

Исходный код приложения: 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 на сервере. Благодаря им можно выполнять кросс-доменные запросы.

CORS error

Устанавливаем 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):

Obtain token

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 )

Конец

Проблема? Используй GitHub Issues.

До следующего раза,
apirobot в 02:00

comments powered by Disqus