Имеется:
- Бакэнд 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'
});