Skip to content

Instantly share code, notes, and snippets.

@FooBarWidget
Created July 26, 2008 15:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save FooBarWidget/2662 to your computer and use it in GitHub Desktop.
Save FooBarWidget/2662 to your computer and use it in GitHub Desktop.
# 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