Rails, разработка аутентификации, выпуск CSRF
Я делаю одностраничное приложение с использованием Rails. При входе и выходе контроллеры Devise вызываются с помощью ajax. Проблема, которую я получаю, состоит в том, что, когда я 1) вход, 2) выход, то вход снова не работает.
Я думаю, что это связано с токеном CSRF, который сбрасывается, когда я выхожу (хотя это не должно повторяться), и так как это одна страница, старый токен CSRF отправляется в запросе xhr, таким образом сбрасывая сеанс.
Чтобы быть более конкретным, это рабочий процесс:
- войти в систему
- выход
- Войти (успешно 201. Однако печатает
WARNING: Can't verify CSRF token authenticity
в логах сервера) - Последующий запрос ajax завершается неудачно 401
- Обновите веб-сайт (в этот момент CSRF в заголовке страницы меняется на что-то другое)
- Я могу войти, это работает, пока я не попытаюсь выйти и снова.
Любые подсказки очень ценятся! Дайте мне знать, если я могу добавить больше деталей.
9 ответов
Джимбо проделал потрясающую работу, объяснив причину проблемы, с которой вы столкнулись. Есть два подхода, которые вы можете предпринять, чтобы решить эту проблему:
(В соответствии с рекомендациями Jimbo) переопределите Devise::SessionsController для возврата нового токена csrf:
class SessionsController < Devise::SessionsController def destroy # Assumes only JSON requests signed_out = (Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)) render :json => { 'csrfParam' => request_forgery_protection_token, 'csrfToken' => form_authenticity_token } end end
И создайте обработчик успеха для вашего запроса на выход из системы на стороне клиента (скорее всего, потребуются некоторые настройки, основанные на вашей настройке, например, GET vs DELETE):
signOut: function() { var params = { dataType: "json", type: "GET", url: this.urlRoot + "/sign_out.json" }; var self = this; return $.ajax(params).done(function(data) { self.set("csrf-token", data.csrfToken); self.unset("user"); }); }
Это также предполагает, что вы автоматически включаете токен CSRF со всеми AJAX-запросами следующим образом:
$(document).ajaxSend(function (e, xhr, options) { xhr.setRequestHeader("X-CSRF-Token", MyApp.session.get("csrf-token")); });
Гораздо проще, если это подходит для вашего приложения, вы можете просто переопределить
Devise::SessionsController
и переопределить проверку токена с помощьюskip_before_filter :verify_authenticity_token
,
Я только что столкнулся с этой проблемой. Здесь много чего происходит.
TL; DR. Причина сбоя заключается в том, что токен CSRF связан с вашим сеансом на сервере (у вас есть сеанс на сервере, вне зависимости от того, вошли вы в систему или вышли из системы). Маркер CSRF включается в DOM вашей страницы при каждой загрузке страницы. При выходе ваша сессия сбрасывается и не имеет токена csrf. Обычно выход из системы перенаправляет на другую страницу / действие, что дает вам новый токен CSRF, но, поскольку вы используете ajax, вам нужно сделать это вручную.
- Вам нужно переопределить метод Devise SessionController::destroy для возврата вашего нового токена CSRF.
- Затем на стороне клиента вам нужно установить обработчик успеха для вашего выхода из XMLHttpRequest. В этом обработчике вам нужно взять этот новый токен CSRF из ответа и установить его в вашем домене:
$('meta[name="csrf-token"]').attr('content', <NEW_CSRF_TOKEN>)
Более подробное объяснение Вы, скорее всего, получили protect_from_forgery
установите в вашем файле ApplicationController.rb, от которого наследуются все остальные ваши контроллеры (я думаю, это довольно часто). protect_from_forgery
выполняет проверки CSRF для всех не-GET запросов HTML/Javascript. Поскольку Devise Login является POST, он выполняет проверку CSRF. Если проверка CSRF завершается неудачно, текущий сеанс пользователя очищается, т. Е. Выходит из системы, поскольку сервер предполагает, что это атака (что является правильным / желаемым поведением).
Итак, предполагая, что вы начинаете в состоянии выхода из системы, вы загружаете новую страницу и никогда не перезагружаете страницу снова:
При рендеринге страницы: сервер вставляет на страницу токен CSRF, связанный с сеансом вашего сервера. Вы можете просмотреть этот токен, запустив следующее из консоли javascript в вашем браузере
$('meta[name="csrf-token"]').attr('content')
,Затем вы входите через XMLHttpRequest: ваш CSRF-токен на этом этапе остается неизменным, поэтому CSRF-токен в вашей сессии все еще совпадает с тем, который был вставлен на страницу. За кулисами на стороне клиента jquery-ujs прослушивает xhr и устанавливает заголовок 'X-CSRF-Token' со значением
$('meta[name="csrf-token"]').attr('content')
автоматически (помните, что это был токен CSRF, установленный на шаге 1 сервером). Сервер сравнивает токен, установленный в заголовке, с помощью jquery-ujs и тот, который хранится в вашей информации о сеансе, и они совпадают, так что запрос выполняется успешно.Затем вы выходите из системы с помощью XMLHttpRequest: это сбрасывает сеанс, дает вам новый сеанс без токена CSRF.
Затем вы снова входите через XMLHttpRequest: jquery-ujs извлекает токен CSRF из значения
$('meta[name="csrf-token"]').attr('content')
, Это значение по-прежнему является вашим старым токеном CSRF. Он берет этот старый токен и использует его для установки 'X-CSRF-Token'. Сервер сравнивает это значение заголовка с новым токеном CSRF, который он добавляет к вашему сеансу, который отличается. Эта разница вызываетprotect_form_forgery
потерпеть неудачу, которая бросаетWARNING: Can't verify CSRF token authenticity
и сбрасывает ваш сеанс, который выходит из системы пользователя.Затем вы создаете еще один XMLHttpRequest, для которого требуется зарегистрированный пользователь: текущий сеанс не имеет зарегистрированного пользователя, поэтому devise возвращает 401.
Обновление: 8/14 Выход из системы Devise не дает вам новый токен CSRF, перенаправление, которое обычно происходит после выхода из системы, дает вам новый токен csrf.
Мой ответ в значительной степени заимствует как из @Jimbo, так и из @Sija, однако я использую соглашение devise/angularjs, предложенное в Rails CSRF Protection + Angular.js: protect_from_forgery, заставляет меня выходить из системы на POST и немного уточняется в моем блоге, когда я Первоначально сделал это. У него есть метод на контроллере приложения, чтобы установить куки для csrf:
after_filter :set_csrf_cookie_for_ng
def set_csrf_cookie_for_ng
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end
Поэтому я использую формат @ Sija, но использую код из этого более раннего SO-решения, давая мне:
class SessionsController < Devise::SessionsController
after_filter :set_csrf_headers, only: [:create, :destroy]
protected
def set_csrf_headers
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end
end
Для полноты из-за того, что мне потребовалось несколько минут, чтобы это решить, я также отмечаю необходимость изменить ваш config/rout.rb, чтобы объявить, что вы переопределили контроллер сессий. Что-то вроде:
devise_for :users, :controllers => {sessions: 'sessions'}
Это также было частью большой очистки CSRF, которую я сделал в своем приложении, которая может быть интересна другим. Сообщение в блоге здесь, другие изменения включают в себя:
Спасение от ActionController::InvalidAuthenticityToken, что означает, что, если что-то не синхронизируется, приложение исправит себя, а не пользователь, которому нужно очистить куки. Я думаю, что ваш контроллер приложения будет по умолчанию с:
protect_from_forgery with: :exception
В этой ситуации вам необходимо:
rescue_from ActionController::InvalidAuthenticityToken do |exception|
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
render :error => 'invalid token', {:status => :unprocessable_entity}
end
У меня также было некоторое горе с условиями гонки и некоторыми взаимодействиями с модулем timetotable в Devise, который я прокомментировал далее в сообщении в блоге - короче говоря, вы должны рассмотреть использование active_record_store вместо cookie_store и быть осторожным при выпуске параллельного запросы рядом с действиями sign_in и sign_out.
Это мое мнение:
class SessionsController < Devise::SessionsController
after_filter :set_csrf_headers, only: [:create, :destroy]
respond_to :json
protected
def set_csrf_headers
if request.xhr?
response.headers['X-CSRF-Param'] = request_forgery_protection_token
response.headers['X-CSRF-Token'] = form_authenticity_token
end
end
end
И на стороне клиента:
$(document).ajaxComplete(function(event, xhr, settings) {
var csrf_param = xhr.getResponseHeader('X-CSRF-Param');
var csrf_token = xhr.getResponseHeader('X-CSRF-Token');
if (csrf_param) {
$('meta[name="csrf-param"]').attr('content', csrf_param);
}
if (csrf_token) {
$('meta[name="csrf-token"]').attr('content', csrf_token);
}
});
Что будет держать ваши мета-теги CSRF обновленными каждый раз, когда вы вернетесь X-CSRF-Token
или же X-CSRF-Param
заголовок через запрос ajax.
После поиска источника Warden я заметил, что настройка sign_out_all_scopes
в false
останавливает Warden от очистки всего сеанса, поэтому токен CSRF сохраняется между выходами из системы.
Связанное обсуждение по решению проблемы Devise: https://github.com/plataformatec/devise/issues/2200
Я просто добавил это в свой файл макета, и это сработало
<%= csrf_meta_tag %>
<%= javascript_tag do %>
jQuery(document).ajaxSend(function(e, xhr, options) {
var token = jQuery("meta[name='csrf-token']").attr("content");
xhr.setRequestHeader("X-CSRF-Token", token);
});
<% end %>
В моем случае после входа пользователя в систему мне нужно было перерисовать меню пользователя. Это сработало, но я получал ошибки аутентификации CSRF при каждом запросе к серверу, в том же разделе (конечно, без обновления страницы). Вышеуказанные решения не работали, так как мне нужно было сделать представление js.
Что я сделал, это с помощью Devise:
приложение / контроллеры /sessions_controller.rb
class SessionsController < Devise::SessionsController
respond_to :json
# GET /resource/sign_in
def new
self.resource = resource_class.new(sign_in_params)
clean_up_passwords(resource)
yield resource if block_given?
if request.format.json?
markup = render_to_string :template => "devise/sessions/popup_login", :layout => false
render :json => { :data => markup }.to_json
else
respond_with(resource, serialize_options(resource))
end
end
# POST /resource/sign_in
def create
if request.format.json?
self.resource = warden.authenticate(auth_options)
if resource.nil?
return render json: {status: 'error', message: 'invalid username or password'}
end
sign_in(resource_name, resource)
render json: {status: 'success', message: '¡User authenticated!'}
else
self.resource = warden.authenticate!(auth_options)
set_flash_message(:notice, :signed_in)
sign_in(resource_name, resource)
yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
end
end
end
После этого я сделал запрос к действию контроллера #, которое перерисовывает меню. И в javascript я изменил X-CSRF-Param и X-CSRF-Token:
Приложение / просмотров / коммунальные услуги /redraw_user_menu.js.erb
$('.js-user-menu').html('');
$('.js-user-menu').append('<%= escape_javascript(render partial: 'shared/user_name_and_icon') %>');
$('meta[name="csrf-param"]').attr('content', '<%= request_forgery_protection_token.to_s %>');
$('meta[name="csrf-token"]').attr('content', '<%= form_authenticity_token %>');
Я надеюсь, что это полезно для кого-то в той же JS ситуации:)
Моя ситуация была еще проще. В моем случае все, что я хотел сделать, это следующее: если человек сидит на экране с формой, и время его сеанса истекло (время ожидания сеанса с тайм-аутом), обычно, если они нажимают "Отправить" в этот момент, Devise вернет его обратно. на экран входа в систему. Ну, я не хотел этого, потому что они теряют все данные своей формы. Я использую JavaScript, чтобы поймать отправку формы, Ajax вызывает контроллер, который определяет, не вошел ли пользователь в систему, и если это так, я помещаю форму, в которой они повторно вводят свой пароль, и я повторно аутентифицирую их (bypass_sign_in в контроллере) с помощью вызова Ajax. Затем можно продолжить отправку исходной формы.
Работал отлично, пока я не добавил protect_from_forgery.
Итак, благодаря приведенным выше ответам все, что мне было нужно, действительно было в моем контроллере, где я снова подписываю пользователя (bypass_sign_in), я просто установил переменную экземпляра для нового токена CSRF:
@new_csrf_token = form_authenticity_token
а затем в обработанном.js.erb (опять же, это был вызов XHR):
$('meta[name="csrf-token"]').attr('content', '<%= @new_csrf_token %>');
$('input[type="hidden"][name="authenticity_token"]').val('<%= @new_csrf_token %>');
Вуаля. Моя страница формы, которая не обновлялась и поэтому застряла со старым токеном, теперь имеет новый токен из нового сеанса, который я получил после входа в систему моего пользователя.
Проверьте, включили ли вы это в файл application.js
//= требуется jquery
//= требуется jquery_ujs
Причина в том, что гем jquery-rails, который автоматически устанавливает токен CSRF для всех запросов Ajax по умолчанию, нуждается в этих двух
В ответ на комментарий @sixty4bit; если вы столкнетесь с этой ошибкой:
Unexpected error while processing request: undefined method each for :authenticity_token:Symbol`
замещать
response.headers['X-CSRF-Param'] = request_forgery_protection_token
с
response.headers['X-CSRF-Param'] = request_forgery_protection_token.to_s