Created
July 26, 2008 15:06
-
-
Save FooBarWidget/2662 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright (c) 2008 Phusion | |
# http://www.phusion.nl/ | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be included in | |
# all copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
# THE SOFTWARE. | |
require 'openssl' | |
require 'digest/sha2' | |
# This library allows one to easily implement so-called "auto-redirections". | |
# | |
# Consider the following use cases: | |
# 1. A person clicks on the 'Login' link from an arbitrary page. After logging in, | |
# he is redirected back to the page where he originally clicked on 'Login'. | |
# 2. A person posts a comment, but posting comments requires him to be logged in. | |
# So he is redirected to the login page, and after a successful login, the | |
# comment that he wanted to post before is now automatically posted. He is also | |
# redirected back to the page where the form was. | |
# | |
# In all of these use cases, the visitor is automatically redirected back to a | |
# certain place on the website, hence the name "auto-redirections". | |
# | |
# Use case 2 is especially interesting. The comment creation action is typically | |
# a POST-only action, so the auto-redirecting with POST instead of GET must also | |
# be possible. This library makes all of those possible. | |
# | |
# | |
# == Basic usage | |
# | |
# Here's an example which shows how use case 1 is implemented. Suppose that you | |
# have a LoginController which handles logins. Instead of calling +redirect_to+ | |
# on a hardcoded location, call +auto_redirect!+: | |
# | |
# class LoginController < ApplicationController | |
# def process_login | |
# if User.authenticate(params[:username], params[:password]) | |
# # Login successful! Redirect user back to original page. | |
# flash[:message] = "You are now logged in." | |
# auto_redirect! | |
# else | |
# flash[:message] = "Wrong username or password!" | |
# render(:action => 'login_form') | |
# end | |
# end | |
# end | |
# | |
# +auto_redirect!+ will take care of redirecting the browser back to where it was, | |
# before the login page was accessed. But how does it know where to redirect to? | |
# The answer: almost every browser sends the "Referer" HTTP header, which tells the | |
# web server where the browser was. +auto_redirect!+ makes use of that information. | |
# | |
# There is a problem however. Suppose that the user typed in the wrong password and | |
# is redirected back to the login page once again. Now the browser will send the URL | |
# of the login page as the referer! That's obviously undesirable: after login, | |
# we want to redirect the browser back to where it was *before* the login page was | |
# accessed. | |
# | |
# So, we insert a little piece of information into the login page's form: | |
# | |
# <% form_tag('/login/process_login') do %> | |
# <%= auto_redirection_information %> <!-- Added! --> | |
# | |
# Username: <input type="text" name="username"><br> | |
# Password: <input type="password" name="password"><br> | |
# <input type="submit" value="Login!"> | |
# <% end %> | |
# | |
# The +auto_redirection_information+ view helper saves the initial referer into a hidden | |
# field called 'auto_redirect_to'. +auto_redirect!+ will use that information instead of | |
# the "Referer" header whenever possible. | |
# | |
# That's it, we're done. :) Every time you do a <tt>redirect_to '/login/login_form'</tt>, | |
# the login page will ensure that the browser is redirected back to where it came from. | |
# | |
# | |
# == Handling POST requests | |
# | |
# Use case 2 is a bit different. We can't rely on the "Referer" HTTP header, because | |
# upon redirecting back, we want the original POST request parameters to be sent as | |
# well. This information is not included in the "Referer" HTTP header. | |
# | |
# So here's an example which shows how use case 3 is implemented. Suppose that you've | |
# changed your LoginController and login view template, as described in 'Basic Usage'. | |
# And suppose you also have a CommentsController. Then call <tt>auto_redirect_to(:here)</tt> | |
# before redirecting to the login page, like this: | |
# | |
# class CommentsController < ApplicationController | |
# def create | |
# if logged_in? | |
# comment = Comment.create!(params[:comment]) | |
# redirect_to(comment) | |
# else | |
# # Redirect visitor to the login page, and tell the login page | |
# # that after it's done, it should redirect back to this place | |
# # (i.e. CommentsController#create), with the exact same | |
# # parameters. | |
# auto_redirect_to(:here) | |
# redirect_to('/login/login_form') | |
# end | |
# end | |
# end | |
# | |
# <tt>auto_redirect_to(:here)</tt> saves information about the current request into | |
# the flash. LoginController's +auto_redirect!+ call will use this information. | |
# | |
# === Nested redirects | |
# | |
# Suppose that there are two places on your website that have a comments form: | |
# '/books' and '/reviews'. And the form currently looks like this: | |
# | |
# <% form_for(@comment) do |f| %> | |
# <%= f.text_area :contents %> | |
# <%= submit_tag 'Post comment' %> | |
# <% end %> | |
# | |
# Naturally, if the visitor is not logged in, then after a login he'll be redirected | |
# to CommentsController#create. But we also want CommentsController#create to redirect | |
# back to '/books' or '/reviews', depending on where he came from. In other words, | |
# we want to be able to *nest* redirection information. | |
# | |
# Right now, CommentsController will always redirect to '/comments/x' after having | |
# created a comments. So we change it a little: | |
# | |
# class CommentsController < ApplicationController | |
# def create | |
# if logged_in? | |
# comment = Comment.create!(params[:comment]) | |
# if !auto_redirect # <-- changed! | |
# redirect_to(comment) # <-- changed! | |
# end # <-- changed! | |
# else | |
# # Redirect visitor to the login page, and tell the login page | |
# # that after it's done, it should redirect back to this place | |
# # (i.e. CommentsController#create), with the exact same | |
# # parameters. | |
# auto_redirect_to(:here) | |
# redirect_to('/login/login_form') | |
# end | |
# end | |
# end | |
# | |
# Now, CommentsController will redirect using auto-redirection information. If no | |
# auto-redirection information is given (i.e. +auto_redirect+ returns false) then | |
# it returns the visitor to '/comments/x'. | |
# | |
# But we're not done yet. The comments form has to tell CommentsController where we | |
# came from. So we modify the comments form template to include that information: | |
# | |
# <% form_for(@comment) do |f| %> | |
# <%= auto_redirect_to(:here) %> <!-- added! --> | |
# <%= f.text_area :contents %> | |
# <%= submit_tag 'Post comment' %> | |
# <% end %> | |
# | |
# === Saving POST auto-redirection information without a session | |
# | |
# The flash is not available if sessions are disabled. In that case, you have to pass | |
# auto-redirection information via a GET parameter, like this: | |
# | |
# redirect_to('/login/login_form', :auto_redirect_to => current_request) | |
# | |
# The +current_request+ method returns auto-redirection information for the | |
# current request. | |
# | |
# == Security | |
# | |
# Auto-redirection information is encrypted, so it cannot be read or tampered with | |
# by third parties. Be sure to set a custom encryption key instead of leaving | |
# the key at the default value. For example, put this in your environment.rb: | |
# | |
# AutoRedirection.encryption_key = "my secret key" | |
# | |
# <b>Tip:</b> use 'rake secret' to generate a random key. | |
module AutoRedirection | |
@@encryption_key = "e1cd3bf04d0a24b2a9760d95221c3dee" | |
@@xhtml = true | |
# The key to use for encryption auto-redirection information. | |
mattr_accessor :encryption_key | |
# Whether this library's view helper methods should output XHTML (instead | |
# of regular HTML). Default: true. | |
mattr_accessor :xhtml | |
# A view template for redirecting the browser back to a place, while | |
# sending a POST request. | |
TEMPLATE_FOR_POST_REDIRECTION = %q{ | |
<% form_tag(@args, { :method => @info['method'], :id => 'form' }) do %> | |
<%= hidden_field_tag('auto_redirect_to', @auto_redirect_to) if @auto_redirect_to %> | |
<noscript> | |
<input type="submit" value="Click here to continue." /> | |
</noscript> | |
<div id="message" style="display: none"> | |
<h2>Your request is being processed...</h2> | |
<input type="submit" value="Click here if you are not redirected within 5 seconds." /> | |
</div> | |
<% end %> | |
<script type="text/javascript"> | |
//<![CDATA[ | |
document.getElementById('form').submit(); | |
setTimeout(function() { | |
document.getElementById('message').style.display = 'block'; | |
}, 1000); | |
// ]]> | |
</script> | |
} | |
end | |
module AutoRedirection | |
module ControllerExtensions | |
protected | |
# Saves auto-redirection information into the flash. | |
# | |
# +location+ may either be +:here+, or a String containing an URL. | |
def auto_redirect_to(location) | |
case location | |
when :here | |
info = { | |
'controller' => controller_path, | |
'action' => action_name, | |
'method' => request.method, | |
'params' => params | |
} | |
flash[:auto_redirect_to] = Encryption.encrypt(Marshal.dump(info), false) | |
logger.debug("Auto-Redirection: saving redirection information " << | |
"for: #{controller_path}/#{action_name} (#{request.method})") | |
when String | |
info = { | |
'url' => location, | |
'method' => 'get' | |
} | |
flash[:auto_redirect_to] = Encryption.encrypt(Marshal.dump(info), false) | |
logger.debug("Auto-Redirection: saving redirection information " << | |
"for: #{location}") | |
else | |
raise ArgumentError, "Unknown location '#{location}'." | |
end | |
end | |
# Returns auto-redirection information for the current request. | |
def current_request | |
@_current_request ||= begin | |
info = { | |
'controller' => controller_path, | |
'action' => action_name, | |
'method' => request.method, | |
'params' => params | |
} | |
Encryption.encrypt(Marshal.dump(info)) | |
end | |
end | |
# The current request may contain auto-redirection information. | |
# If auto-redirection information is given, then this method will redirect | |
# the HTTP client to that location (by calling +redirect_to+) and return true. | |
# Otherwise, false will be returned. | |
# | |
# Auto-redirection information is obtained from the following sources, in | |
# the specified order: | |
# 1. The +auto_redirect_to+ request parameter. | |
# 2. The +auto_redirect_to+ flash entry. | |
# 3. The "Referer" HTTP header. | |
# | |
# In other words: by default, +auto_redirect+ will redirect the HTTP client back | |
# to whatever was specified by the previous +auto_redirect_to+ controller call | |
# or +auto_redirection_information+ view helper call. | |
def auto_redirect | |
info = auto_redirection_information | |
if info.nil? | |
return false | |
end | |
# The page where we're redirecting to might have redirection information | |
# as well. So we save that information to flash[:auto_redirect_to] to | |
# allow nested auto-redirections. | |
if info['method'] == :get | |
if info['url'] | |
logger.debug("Auto-Redirection: redirect to URL: #{info['url']}") | |
redirect_to info['url'] | |
else | |
args = info['params'].merge( | |
:controller => info['controller'], | |
:action => info['action'] | |
) | |
logger.debug("Auto-Redirection: redirecting to: " << | |
"#{info['controller']}/#{info['action']} (get), " << | |
"parameters: #{info['params'].inspect}") | |
redirect_to args | |
end | |
else | |
@info = info | |
@auto_redirect_to = info['params']['auto_redirect_to'] | |
@args = info['params'].merge( | |
:controller => info['controller'], | |
:action => info['action'] | |
) | |
@args.delete('auto_redirect_to') | |
logger.debug("Auto-Redirection: redirecting to: " << | |
"#{@args['controller']}/#{@args['action']} (#{info['method']}), " << | |
"parameters: #{info['params'].inspect}") | |
render :inline => TEMPLATE_FOR_POST_REDIRECTION, :layout => false | |
end | |
return true | |
end | |
# Just like +auto_redirect+, but will redirect to +root_path+ if no | |
# redirection information is found. | |
def auto_redirect! | |
if !auto_redirect | |
redirect_to root_path | |
end | |
end | |
private | |
# Retrieve the auto-redirection information that has been passed. Returns nil | |
# if no auto-redirection information can be found. | |
def auto_redirection_information | |
if !@_auto_redirection_information_given | |
if params.has_key?(:auto_redirect_to) | |
info = Marshal.load(Encryption.decrypt(params[:auto_redirect_to])) | |
elsif flash.has_key?(:auto_redirect_to) | |
info = Marshal.load(Encryption.decrypt(flash[:auto_redirect_to], false)) | |
elsif request.headers["Referer"] | |
info = { | |
'url' => request.headers["Referer"], | |
'method' => :get | |
} | |
else | |
info = nil | |
end | |
@_auto_redirection_information_given = true | |
@_auto_redirection_information = info | |
end | |
return @_auto_redirection_information | |
end | |
end | |
module ViewHelpers | |
def auto_redirection_information | |
info = controller.send(:auto_redirection_information) | |
return render_auto_redirection_information(info) | |
end | |
def auto_redirect_to(location) | |
case location | |
when :here | |
info = { | |
'controller' => controller.controller_path, | |
'action' => controller.action_name, | |
'method' => controller.request.method, | |
'params' => controller.params | |
} | |
logger.debug("Auto-Redirection: saving redirection information " << | |
"for: #{controller.controller_path}/#{controller.action_name}" << | |
" (#{request.method}), parameters: #{controller.params.inspect}") | |
else | |
raise ArgumentError, "Unknown location '#{location}'." | |
end | |
return render_auto_redirection_information(info) | |
end | |
def render_auto_redirection_information(info) | |
if info | |
value = h(Encryption.encrypt(Marshal.dump(info))) | |
html = %Q{<input type="hidden" name="auto_redirect_to" value="#{value}"} | |
if AutoRedirection.xhtml | |
html << " /" | |
end | |
html << ">" | |
return html | |
else | |
return nil | |
end | |
end | |
end | |
# Convenience module for encrypting data. Properties: | |
# - AES-CBC will be used for encryption. | |
# - A cryptographic hash will be inserted so that the decryption method | |
# can check whether the data has been tampered with. | |
class Encryption | |
SIGNATURE_SIZE = 512 / 8 # Size of a binary SHA-512 hash. | |
# Encrypts the given data, which may be an arbitrary string. | |
# | |
# If +ascii7+ is true, then the encrypted data will be returned, in a | |
# format that's ASCII-7 compliant and URL-friendly (i.e. doesn't | |
# need to be URL-escaped). | |
# | |
# Otherwise, the encrypted data in binary format will be returned. | |
def self.encrypt(data, ascii7 = true) | |
signature = Digest::SHA512.digest(data) | |
encrypted_data = aes(:encrypt, AutoRedirection.encryption_key, signature << data) | |
if ascii7 | |
return encode_base64_url(encrypted_data) | |
else | |
return encrypted_data | |
end | |
end | |
# Decrypt the given data, which was encrypted by the +encrypt+ method. | |
# | |
# The +ascii7+ parameter specifies whether +encrypt+ was called with | |
# its +ascii7+ argument set to true. | |
# | |
# If +data+ is nil, then nil will be returned. Otherwise, it must | |
# be a String. | |
# | |
# Returns the decrypted data as a String, or nil if the data has been | |
# corrupted or tampered with. | |
def self.decrypt(data, ascii7 = true) | |
if data.nil? | |
return nil | |
end | |
if ascii7 | |
data = decode_base64_url(data) | |
end | |
decrypted_data = aes(:decrypt, AutoRedirection.encryption_key, data) | |
if decrypted_data.size < SIGNATURE_SIZE | |
return nil | |
end | |
signature = decrypted_data.slice!(0, SIGNATURE_SIZE) | |
if Digest::SHA512.digest(decrypted_data) != signature | |
return nil | |
end | |
return decrypted_data | |
rescue OpenSSL::CipherError | |
return nil | |
end | |
def self.aes(m, k, t) | |
cipher = OpenSSL::Cipher::Cipher.new('aes-256-cbc').send(m) | |
cipher.key = Digest::SHA256.digest(k) | |
return cipher.update(t) << cipher.final | |
end | |
# Encode the given data with "modified Base64 for URL". See | |
# http://tinyurl.com/5tcnra for details. | |
def self.encode_base64_url(data) | |
data = [data].pack("m") | |
data.gsub!('+', '-') | |
data.gsub!('/', '_') | |
data.gsub!(/(=*\n\Z|\n*)/, '') | |
return data | |
end | |
# Encode the given data, which is in "modified Base64 for URL" format. | |
# This method never raises an exception, but will return invalid data | |
# if +data+ is not in a valid format. | |
def self.decode_base64_url(data) | |
data = data.gsub('-', '+') | |
data.gsub!('_', '/') | |
padding_size = 4 - (data.size % 4) | |
data << ('=' * padding_size) << "\n" | |
return data.unpack("m*").first | |
end | |
end | |
end # module AutoRedirection | |
ActionController::Base.send(:include, AutoRedirection::ControllerExtensions) | |
ActionView::Base.send(:include, AutoRedirection::ViewHelpers) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment