среда, 15 февраля 2017 г.

Ember - Django session authentication

Имеется:
- Бакэнд Django.
- Фронтэнд Ember.js


Задача: При входе в Ember (фронтэнд) автоматически проходить аутентификацию (ember simple authentication), если в Django (бакэнд) аутентификацию уже прошла, т.е. получить access_token в Ember для имеющейся пользовательской Django сессии.

Зачем это нужно?
1) Если на бакэнде пользователь уже авторизован, то на фронэнде повторно проходить аутентификацию уже не нужно.
2) В Django реализована аутентификаци при помощи REMOTE_USER - https://docs.djangoproject.com/en/dev/howto/auth-remote-user/ (это когда используется kerberos аутентификация) и, в таком случае, зачем заставлять пользователя проходить повторную аутентификацию на фронэнде? Если простыми словами: пользователь входит в Windows -> запускает браузер и в браузере уже не нужно проходить повторную аутентификацию. Ранее, я уже описывал как сделать Django аутентификацию через NGINX kerberos (Active Directory) , поэтому повторяться здесь не буду.


У меня Django бакэнд лежит в папке back, соответсвенно настройки settings.py лежат в back/back/settings.py . Подробно про создание среды разработки Django Ember

На бакэнэде нужно дополнительно установить:

$ pip install djangorestframework
$ pip install djangorestframework-jwt
$ pip install django-cors-headers


и настроить settings.py:

- в INSTALLED_APPS добавляю 'rest_framework' и 'corsheaders'

INSTALLED_APPS = (
   ...
    # external apps
    'rest_framework',
    'corsheaders',
)


- в MIDDLEWARE_CLASSES добавляю 'corsheaders.middleware.CorsMiddleware',

MIDDLEWARE_CLASSES = [
   ...
   'corsheaders.middleware.CorsMiddleware',
   'django.middleware.common.CommonMiddleware',
   ...
]


- добавляю новую конфигурацию для REST_FRAMEWORK

REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
# разрешаю подключение к api всем, ограничивать подключения буду во views
'rest_framework.permissions.AllowAny',
],
'DEFAULT_AUTHENTICATION_CLASSES': (
# аутентификацию делаю на уровне сессий (для прямых веб подключений) и с использованием token для фронтэнда
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',

'rest_framework.authentication.SessionAuthentication', 
),
'PAGE_SIZE': 100,
}


Для REST_FRAMEWORK аутентификация на первом месте обязательно должна быть JSONWebTokenAuthentication. Это очень важно, потому что сначала делается попытка пройти аутентификацию по jwt, а если не получается, то идет аутентификация по сессии. И если поставить сначала аутентификацию по сессии, то на фронтэнде аутентификация будет выполнена по SessionAuthentication и при POST запросе будет вылетать ошибка, т.к. аутентификация по сессии использует CSRF.  Вообщем-то, когда будет все отлажено и запущено, можно вообще убрать аутентификацию SessionAuthentication.


- для CORS разрешаю все запросы во время разработки (DEBUG = True, а значит получится и CORS_ORIGIN_ALLOW_ALL = True), а также разрешаю принимать cookie в в заголоках - CORS_ALLOW_CREDENTIALS = True:

CORS_ORIGIN_ALLOW_ALL = DEBUG
 CORS_ALLOW_CREDENTIALS = True

- для djangorestframework-jwt (JSON Web Token) добавляю настройки:

import datetime

JWT_AUTH = {
'JWT_RESPONSE_PAYLOAD_HANDLER': 'back.views.jwt_response_payload_handler',
'JWT_AUTH_HEADER_PREFIX': 'Bearer',
# default JWT_EXPIRATION_DELTA - 5 minutes
# JWT_EXPIRATION_DELTA = datetime.timedelta(seconds=300)
'JWT_EXPIRATION_DELTA': datetime.timedelta(hours=12),
}


Мне не хватает для разработки 5 минут активного токена jwt, которые стоят по умолчанию, поэтому я сделал 12 часов, не надо передергивать постояно. Надо понимать, что быстрое "протухание" токена сделано с целью безопасности, поэтому на рабочих проектах надо поставить оптимальное время.

- на этом настройки settings.py закончены, сохраняю.

Т.к. я сослался в настройках на  'JWT_RESPONSE_PAYLOAD_HANDLER': 'back.views.jwt_response_payload_handler', то надо теперь создать
back/back/views.py
и добавить функцию, которая меняет название ключа 'token' на 'access_token'. (по умолчанию djangorestframework-jwt выдает в json ответе ключ 'token', а ember-simple-auth хочет видеть 'access_token'):


def jwt_response_payload_handler(token, user=None, request=None):
    return {
        'access_token': token,
    }


Чтобы не прыгать от настроек к настройкам, добавлю сразу в back/back/views.py функцию отдачи jwt ключа по POST запросу, который будет создаваться при наличии пользовательской сессии.

Привожу полный back/back/views.py

from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
from rest_framework import serializers
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework_jwt.settings import api_settings
from rest_framework import status


#получаю пользователя из сессии

def get_user_from_session(session_key):
    try:
        session = Session.objects.get(session_key = session_key)
        uid = session.get_decoded().get('_auth_user_id')
        return User.objects.get(pk = uid)
    except:
        return None


@api_view(['POST'])
def SessionIdJSONWebToken(request):
    if request.method == 'POST':
        session_key = request.session.session_key
        user = get_user_from_session(session_key)
        if user:
            #Создаю jwt токен для отдачи
            jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
            jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
            payload = jwt_payload_handler(user)
            token = jwt_encode_handler(payload)
            username = user.username
            response_data = jwt_response_payload_handler(token, user, request)
            return Response(response_data)
        else:

            #если нет пользовательской сесии выдаю ошибку 401
            response_data = {"error":"User session not found."}
            return Response(response_data, status=status.HTTP_401_UNAUTHORIZED)

#добавляю дополнительно user.id и user.username в jwt json ответ, 

# чтобы на фронтэнде через сессии можно было посмотреть 
# авторизованного пользователя без дополнительных запросов
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ('id','username',)


def jwt_response_payload_handler(token, user=None, request=None):
    return {
        'access_token': token,
        'user': UserSerializer(user, context={'request': request}).data,
    }



Добавляю в back/back/urls.py


from django.conf.urls import url, include
from django.contrib import admin

from rest_framework_jwt.views import obtain_jwt_token, verify_jwt_token, refresh_jwt_token
from .views import SessionIdJSONWebToken


 

urlpatterns = [
    #url(r'^', include('front.urls')),
    url(r'^admin/', admin.site.urls),
    url(r'^api/auth/login/', obtain_jwt_token),
    url(r'^api/auth/verify-token/', verify_jwt_token),
    url(r'^api/auth/token-sessionid/', SessionIdJSONWebToken),
]



Проверяем отдачу 'access_token' на бакэнде.
- Если еще не создан супер пользователь, то создаем:
$ cd back
$ python manage.py createsuperuser

затем запускаю Django бакэнд:
$ python manage.py runserver

Захожу в админку - http://localhost:8000/admin/
После успешного входа иду по адресу - http://localhost:8000/api/auth/token-sessionid/ , где вижу
"detail": "Method \"GET\" not allowed."
все правильно, принимаются только POST запросы.

Ввожу в поле Content пустые фигурные скобки {} и нажимаю кнопку "POST". Получаю json ответ на экране

HTTP 200 OK
Allow: POST, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "user": {
        "id": 2,
        "username": "hairetdin"
    },
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImhhaXJldGRpbiIsInVzZXJfaWQiOjIsImVtYWlsIjoiIiwiZXhwIjoxNDg3MTQ1MDExfQ.kcl2TKs3pCd8nk8DIme9tzzWW7UUuy6JM-P21yly7Ig"
}

Почему запрос не через curl? Вроде проще, а нет. В консоли нет пользовательской сессии. Можно попробывать и посмотреть

$ curl -X POST http://localhost:8000/api/auth/token-sessionid/
{"error":"User session not found."}


Проверяем json token при аутентификации - http://localhost:8000/api/auth/login/ и верификации - http://localhost:8000/api/auth/verify-token/. Соответсвенно ввожу логин и пароль или в поле Token вставляю 'access_token' из предыдущей страницы.
Или в командной строке:
$ curl -X POST -d "username=admin&password=парольсуперпользователя" http://127.0.0.1:8000/api/auth/login/


С бакэндом закончили, переходим к фронтэнду.

Фронтэнд - Ember js. Детали описывать не буду, если что-то непонятно, то Подробно про создание среды разработки Django Ember

$ cd front

- Устанавливаю EDA (Ember-Django-Adapter)
$ ember install ember-django-adapter

- Устанавливаю для аутентификации, авторизации - ember-simple-auth
$ ember install ember-simple-auth

- Устанавливаю ember-cli-js-cookie, чтобы можно было вызывать cookie 
$ ember install ember-cli-js-cookie

В настройках front/config/environment.js указываю API_HOST бакэнда и путь для ember-simple-auth:

if (environment === 'development') { 
  ENV.APP.API_HOST = 'http://localhost:8000';
  ENV['ember-simple-auth'] = {
    serverAuthEndpoint: ENV.APP.API_HOST + '/api/auth/'
  }
... 
}

- Хоть и ведутся какие-то неправильные разговоры про pods, я его все равно использую и далее планирую использовать, мне нравится когда каждое приложение лежит в отдельной папке. Поэтому, назначаю по умолчанию pods структуру в файле front/.ember-cli

{
  "usePods": true,

}

- Создаю аутентификатор
$ ember g authenticator django

Аутентификатор создался по пути front/app/authenticators/django.js
Вношу в front/app/authenticators/django.js следующий код:

//начало front/app/authenticators/django.js
import Ember from 'ember';
import Base from 'ember-simple-auth/authenticators/base';
import ENV from '../config/environment';
import Cookies from 'ember-cli-js-cookie';


function isSecureUrl(url) {
  var link  = document.createElement('a');
  link.href = url;
  link.href = link.href;
  return link.protocol === 'https:';
}

export default Base.extend({

  init() {
    //var apiHost = ENV.APP.API_HOST;
    var apiAuthentication = ENV['ember-simple-auth'] || {};
    this.serverAuthEndpoint = apiAuthentication.serverAuthEndpoint;

  },

  authenticate(identification,password) {

    return new Ember.RSVP.Promise((resolve, reject) => {
      let remoteResponse;
      if (!(identification && password)) {
        remoteResponse = this.requestBackendSession();
      } else {
        const data = { username: identification, password: password };
        let host = ENV.APP.API_HOST;
        let login_url = host + '/api/auth/login/';
        //let login_url = this.serverAuthEndpoint + 'login/';
        remoteResponse = this.makeRequest(login_url, data);
      }
      remoteResponse
      .then((response) => {
        Ember.run(() => {
          console.log('django authenticate response:',response);
          resolve(response);
        });
      }, (xhr /*, status, error */) => {
        Ember.run(() => {
          reject(xhr.responseJSON || xhr.responseText);
        });
      });
    });
  },

  restore(data) {
    return new Ember.RSVP.Promise((resolve, reject) => {
      console.log('authenticator django restore data', data);
      let host = ENV.APP.API_HOST;
      let verifyTokenUrl = host + '/api/auth/verify-token/';
      let access_token = {token:data.access_token};
      this.makeRequest(verifyTokenUrl, access_token).then((response) => {
        Ember.run(() => {
          resolve(response);
        });
      }, (xhr /*, status, error */) => {
        Ember.run(() => {
          reject(xhr.responseJSON || xhr.responseText);
        });
      });
    });
  },


  invalidate(/* data */) {
    function success(resolve) {
      resolve();
    }
    return new Ember.RSVP.Promise((resolve /*, reject */) => {
      //let logout_url = this.serverAuthEndpoint + 'logout/';
      let host = ENV.APP.API_HOST;
      let logout_url = host + '/api/auth/logout/';
      this.makeRequest(logout_url, {}).then((/* response */) => {
        Ember.run(() => {
          success(resolve);
        });
      }, (/* xhr, status, error */) => {
        Ember.run(() => {
          success(resolve);
        });
      });
    });
  },

  makeRequest(url, data) {
    if (!isSecureUrl(url)) {
      Ember.Logger.warn('Credentials are transmitted via an insecure connection - use HTTPS to keep them secure.');
    }

    return new Ember.RSVP.Promise((resolve, reject) => {
      Ember.$.ajax({
        url: url,
        type: 'POST',
        beforeSend: function(request) {
          request.setRequestHeader("X-CSRFToken", Cookies.get('csrftoken'));
        },
        data: data,
      })
      .then((response) => {
        if (response.status === 400) {
          response.json().then((json) => {
            reject(json);
          });

        } else if (response.status > 400) {
          reject(response);
        } else {
          resolve(response);
        }
      }).catch((err) => {
        reject(err);
      });
    });
  },

  requestBackendSession(){

    let host = ENV.APP.API_HOST;
    let url = host + '/api/auth/token-sessionid/';
    let csrftoken = Cookies.get('csrftoken');
    return new Ember.RSVP.Promise((resolve, reject) => {
      Ember.$.ajax({
        url: url,
        type: 'POST',
        beforeSend: function(xhr/*, settings*/) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        },
        xhrFields: {
            withCredentials: true
        },
      })
      .then((response) => {
        if (response.status === 400) {
          response.json().then((json) => {
            reject(json);
          });

        } else if (response.status > 400) {
          reject(response);
        } else {
          resolve(response);
        }
      }).catch((err) => {
        reject(err);
      });
    });
  }
});

//конец front/app/authenticators/django.js


- Генерирую ресурс и контроллер  application:

$ ember g resource application
$ ember g controller application

- Добавляю в темплейт front/app/application/template.hbs следующий код:

{{!-- front/app/application/template.hbs --}}
<div class="menu">
  {{#if session.isAuthenticated}}
    {{session.data.authenticated.user.username}}
    <a href {{action 'invalidateSession'}}>Выход</a>
  {{else}}
    {{#link-to 'login'}}Вход{{/link-to}}
  {{/if}}
</div>
<div class="main">
  {{outlet}}
</div>


- Добавляю в контроллер front/app/application/controller.js следующий код:

// front/app/application/controller.js
import Ember from 'ember';

export default Ember.Controller.extend({
  session: Ember.inject.service('session'),





  sessionAuthenticated: Ember.observer('session.isAuthenticated', function () {
    //reload application if session isAuthenticated
    window.location.href = '/';
  }),

  actions: {
    invalidateSession() {
      this.get('session').invalidate();
    }
  }
});



- Добавляю в роут front/app/application/route.js следующий код:

// front/app/application/route.js
import Ember from 'ember';
import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin';

export default Ember.Route.extend(ApplicationRouteMixin,{
  session: Ember.inject.service('session'),
  beforeModel: function(){
    if (!(this.get('session.isAuthenticated'))){
      //try to authenticate with backend's session, if session is not authenticated
      this.get('session').authenticate('authenticator:django');
    }
  },

});


Вообщем-то на этом этапе уже можно наблюдать результат:
- Запускаем бакэнд - python manage.py runserver
- Запускаем фронтэнд - ember s
На бакэнде проходим аутентификацию на странице http://localhost:8000/admin/login/, а на странице фронтэнда - http://localhost:4200/ смотрим результат в консоли (кнопка F12):

django authenticate response: Object {user: Object, access_token: ....

а еще выводится ошибка

Error: There is no route named login

это потому, что в темплейте front/app/application/template.hbs имеется ссылка {{#link-to 'login'}}Вход{{/link-to}} на роут login, а такого роута пока нет. Исправим эту ошибку.
- Создаю роут и контроллер для login
$ ember g route login
$ ember g controller login

- Редактирую login темлейт front/app/login/template.hbs:

{{!-- front/app/login/template.hbs --}}
<form {{action 'authenticate' on='submit'}}>
  <label for="identification">Пользователь</label>
  {{input id='identification' placeholder='Enter Login' value=identification}}
  <label for="password">Пароль</label>
  {{input id='password' placeholder='Enter Password' type='password' value=password}}
  <button type="submit">Вход</button>
  {{#if errorMessage}}
    <p>{{errorMessage}}</p>
  {{/if}}
</form>


- Редактирую login контроллер front/app/login/controller.js:

// front/app/login/controller.js
import Ember from 'ember';

export default Ember.Controller.extend({
  session: Ember.inject.service('session'),

  actions: {
    authenticate() {
      let { identification, password } = this.getProperties('identification', 'password');

      this.get('session').authenticate('authenticator:django', identification, password).
      then(() => {
       console.log('Успешна аутентификация с токеном: ' + this.get('session.data.authenticated.access_token'));
      }, (err) => {
       alert('Ошибка токена: ' + err.responseText);
      }).
      catch((reason) => {
      this.set('errorMessage', reason.error || reason);
      });
    }
  }
});


Теперь в http://localhost:4200/ ошибок нет и возможна аутентификация через фронэнд, если на бакэнде аутентификации не было.


А вот авторизатор я изобретать не буду, использую стандартный.
Создаю авторизатор:
$ ember g authorizer django

и добавлю в него (front/app/authorizers/django.js) следующие строки

// front/app/authorizers/django.js
import OAuth2Bearer from 'ember-simple-auth/authorizers/oauth2-bearer';

export default OAuth2Bearer.extend();


Теперь нужен еще адаптер, который будет использовать созданный django авторизатор

$ ember g adapter application

и добавляю в него (front/app/application/adapter.js) следующий код:

// front/app/application/adapter.js
import DRFAdapter from '../adapters/drf';
import DataAdapterMixin from 'ember-simple-auth/mixins/data-adapter-mixin';

export default DRFAdapter.extend(DataAdapterMixin , {
  authorizer: 'authorizer:django'
});





Комментариев нет:

Отправить комментарий

django-oscar tinymce 4 filebrowser

Задача: в дашборде django-oscar загружать изображения 1. Установка django-filebrowser-no-grappelli - Открываем проект, загружаем виртуа...