Unser Seitenprojekt Hotelbewertung.de wurde umfassend neu aufgelegt. Eine Anforderung dabei war, bestimmte Seiten per SSL sicher zu übertragen, andere wiederum nicht. Was sich nach einem trivialen Problem anhört, hatte es dann doch ganz schön in sich, weil Rails vor allem beim Routing bzw. der Routengenerierung diesbezüglich nicht viel zu bieten hat. An diesem Punkt haben wir angesetzt und eine elegante Lösung entwickelt.
Einleitung
Bestimmte Seiten verlangen die Eingabe von persönlichen Daten und sollen deswegen selbstverständlich per SSL abgesichert sein. Andere Seiten wiederum binden Partnermaterial ein, das nicht per HTTPS verfügbar ist. Eine Protokollmischung auf einer Seite würde Browserwarnungen hervorrufen, deswegen müssen diese Seiten per HTTP statt HTTPS ausgeliefert werden.
Was ist das Problem?
Grundsätzlich ist es nicht schwierig, die Produktivwebserver so zu
konfigurieren, dass sie HTTP- wie HTTPS-Anfragen beantworten und an die gleiche
Rails-Anwendung weiterleiten.
Jetzt folgt schon die erste Hürde: Im Routing muss definiert werden, welche
Pfade mit und welche ohne SSL angefragt werden können.
Das größere Problem ist die korrekte Routengenerierung, dass also das gewünschte
Protokoll berücksichtigt wird, idealerweise sogar bei der Nutzung
von _path
-Helpern.
Vorbereitungen
Meine Ausführungen beziehen sich auf die gerade aktuelle Version von Rails, 3.2.11 und auf Sass-Rails in der Version 3.2.5. Neuere Versionen handhaben die Probleme vielleicht schon selbst, vielleicht funktionieren meine Monkey-Patches auch nicht mehr korrekt. Hier ist also Vorsicht geboten.
Zur lokalen Entwicklung mit den zwei Protokollen benötigt man auch zwei App-Server. Mit Passenger ließe sich gegebenenfalls ein Setup mit weniger Servern erstellen, hier arbeiten wir aber der Einfachheit halber mit Thin.
Unsere Entwicklungsumgebungen verwalten wir in der Regel mit tmuxinator, für ERB-Unterstützung wird allerdings unser fork benötigt.
project_name: ssl_test
project_root: ~/Tests/ssl_test
rvm: 1.9.3
tabs:
- server:
- bundle exec thin start --port 4001 -a <%= `hostname -f` %>
- "ssl-server":
- bundle exec thin start --ssl --port 4002 -a <%= `hostname -f` %>
Thin wird hier explizit an einen Hostnamen gebunden, sodass die App-Server nicht
unter localhost
oder ihrer IP erreichbar sind.
Dies beugt Problemen mit der
Same-Origin-Policy
aller Web-Browser vor, die man bei ajax requests auf feste URLs dann bekäme.
1. Routingweiche
Welche Pfade können nur per HTTP oder nur per HTTPS angefragt werden? Dazu
müssen wir natürlich in die routes.rb
und entsprechende constraints
setzen:
# routes.rb
SslTest::Application.routes.draw do
scope constraints: { protocol: 'http://' } do
match '/nonssl' => 'test#nonssl'
end
scope constraints: { protocol: 'https://' } do
match '/ssl' => 'test#ssl'
end
end
Weiterer Code zum Testen:
# test_controller.rb
class TestController < ApplicationController
def nonssl
render text: 'hello nonssl'
end
def ssl
render text: 'hello ssl'
end
end
Schnelle Tests mit dem Browser zeigen, dass der erste Pfad nur ohne, der zweite nur mit SSL erreichbar ist.
2. Routen werden falsch generiert
Erweitert man jetzt controller und views, dann stößt man auf das zweite Problem. Die Routen werden gegebenenfalls mit dem falschen Protokollpräfix generiert. Der folgende Beispielcode veranschaulicht die Problematik:
# test_controller.rb
class TestController < ApplicationController
def nonssl
render 'default'
end
def ssl
render 'default'
end
end
<!-- default.html.erb -->
<pre>
<%= nonssl_path %> - <%= nonssl_url %>
<%= ssl_path %> - <%= ssl_url %>
</pre>
Zwei Aufrufe liefern:
<!-- http://localhost:4001/nonssl -->
/nonssl - http://localhost:4001/nonssl
/ssl - http://localhost:4001/ssl
<!-- https://localhost:4002/ssl -->
/nonssl - https://localhost:4002/nonssl
/ssl - https://localhost:4002/ssl
Zwei Fehler zeigen sich: Das angegebene Protokoll der vollen URLs ist teilweise falsch, die Pfade sind teilweise nicht gültig.
2a. Protokoll in vollen URLs falsch
Dieser Fehler lässt sich relativ leicht korrigieren. Im routing muss das Protokoll als Parameter für die jeweiligen scopes gesetzt werden:
# routes.rb
SslTest::Application.routes.draw do
scope protocol: 'http://', constraints: { protocol: 'http://' } do
match '/nonssl' => 'test#nonssl'
end
scope protocol: 'https://', constraints: { protocol: 'https://' } do
match '/ssl' => 'test#ssl'
end
end
<!-- http://localhost:4001/nonssl -->
/nonssl - http://localhost:4001/nonssl
/ssl - https://localhost:4001/ssl
<!-- https://localhost:4002/ssl -->
/nonssl - http://localhost:4002/nonssl
/ssl - https://localhost:4002/ssl
Die Pfade sind noch falsch, die vollen URLs stimmen aber jetzt schon fast. Dass die Portangaben im Entwicklungsmodus ebenfalls noch falsch sind ist unschön, wird aber im nächsten Schritt mit gelöst.
2b. Ungültige Pfade
Rails generiert Pfade, die für das aktuelle Protokoll gegebenenfalls gar nicht gültig sind. Toll wäre, wenn in diesem Fall dann automatisch volle URLs statt Pfaden generiert werden würden. Ein Monkey-Patch zur Routengenerierung stellt das sicher:
# config/initializers/routing_fixes.rb
module ActionDispatch::Routing::UrlFor
def url_for_with_protocol_switch(options = nil)
if options.is_a?(Hash)
if Rails.env.development?
if options[:protocol] == 'https://'
options[:port] = 4002
else
options[:port] = 4001
end
end
# 2 Fälle wenn immer eine volle URL generiert werden muss:
# * Wenn die Umgebung unklar ist, z. B. während der Generierung der
# assets
# * Wenn der Link das Protokoll wechseln würde
if !defined?(params) || params[:protocol] != options[:protocol]
options[:only_path] = false
end
end
url_for_without_protocol_switch(options)
end
alias_method_chain :url_for, :protocol_switch
end
<!-- http://localhost:4001/nonssl -->
/nonssl - http://localhost:4001/nonssl
https://localhost:4002/ssl - https://localhost:4002/ssl
<!-- https://localhost:4002/ssl -->
http://localhost:4001/nonssl - http://localhost:4001/nonssl
/ssl - https://localhost:4002/ssl
Voilà, richtige Portnummern und volle URLs, wenn Pfade falsch wären! Der Patch ist genial, hat aber leider noch ein paar Nachteile.
Capybara
Die _url
-Helper funktionieren nicht mit Capybara, deswegen deaktivieren
wir den Patch in der Test-Umgebung.
Sass-Rails
Man kann die Routen in Sprockets zur Verfügung stellen, z. B. so:
# config/initializers/sprockets.rb
clz = SslTest::Application.assets.context_class
clz.instance_eval do
include Rails.application.routes.url_helpers
cattr_reader :default_url_options
end
clz.class_variable_set '@@default_url_options',
{ host: `hostname -f`.rstrip,
port: nil, protocol: nil }
Ein Problem tritt dann bei der Asset-Generierung auf, wenn man @import
mit einer Pfadangabe verwendet und Root-Routen definiert
hat. rake assets:precompile
wirft dann den
Fehler different prefix: "/" and "http:/www.ssltest.de"
Sass-Rails erwartet in Importer#resolve
in diesem Fall nicht,
dass #root_path
eine volle URL liefert. Ich monkey-patche die Methode
folgendermaßen:
# config/initializers/routing_fixes.rb
module Sass::Rails
class Importer
def resolve(name, base_pathname = nil)
name = Pathname.new(name)
if base_pathname && base_pathname.to_s.size > 0
#root = Pathname.new(context.root_path)
root = Pathname.new('/')
name = base_pathname.relative_path_from(root).join(name)
end
partial_name = name.dirname.join("_#{name.basename}")
@resolver.resolve(name) || @resolver.resolve(partial_name)
end
end
end
Zusammengefasst
Aus diesem langen Artikel folgt doch ein relativ übersichtlicher patch:
# config/initializers/routing_fixes.rb
unless Rails.env.test?
module ActionDispatch::Routing::UrlFor
def url_for_with_protocol_switch(options = nil)
if options.is_a?(Hash)
if Rails.env.development?
if options[:protocol] == 'https://'
options[:port] = 4002
else
options[:port] = 4001
end
end
# 2 Fälle wenn immer eine volle URL generiert werden muss:
# * Wenn die Umgebung unklar ist, z. B. während der Generierung der
# assets
# * Wenn der Link das Protokoll wechseln würde
if !defined?(params) || params[:protocol] != options[:protocol]
options[:only_path] = false
end
end
url_for_without_protocol_switch(options)
end
alias_method_chain :url_for, :protocol_switch
end
end
# Während der Asset-Generierung liefert #root_path auch eine volle URL, was
# sass-rails aber nicht erwartet, deswegen monkey-patche ich hier die
# betreffende Methode.
module Sass::Rails
class Importer
def resolve(name, base_pathname = nil)
name = Pathname.new(name)
if base_pathname && base_pathname.to_s.size > 0
#root = Pathname.new(context.root_path)
root = Pathname.new('/')
name = base_pathname.relative_path_from(root).join(name)
end
partial_name = name.dirname.join("_#{name.basename}")
@resolver.resolve(name) || @resolver.resolve(partial_name)
end
end
end