QVINTVS · SCRIBET

Padrino und Warden: SystemStackError

Im Zusammenspiel von Padrino/Sinatra und Warden kann es bei unvorsichtiger Bedienung zu einem SystemStackError kommen. Um die Ursache dafür zu finden, habe ich ziemlich lange suchen müssen, daher halte ich meine Ergebnisse hier für die Nachwelt fest.

Zunächst einmal mein Setup. Es handelt sich um eine relativ einfache Padrino-Anwendung, die auf DataMapper aufsetzt und die ganz klassisch ein Admin-Model besitzt:

class Admin
  include DataMapper::Resource

  property :id,                 Serial
  property :full_name,          String, :required => true
  property :user_name,          String, :required => true, :unique => true
  property :encrypted_password, String, :required => true

  #...

  def password=(new_password)
    self.encrypted_password = BCrypt::Password.create(new_password)
  end

  def authenticate(password)
    BCrypt::Password.new(encrypted_password) == password
  end

end

Also ordentliche Verschlüsselung mit BCrypt, wobei das fertige Passwort in der Spalte encrypted_password gesichert wird. Die Dazugehörige Warden-Strategie sieht vereinfacht so aus:

Warden::Strategies.add(:encrypted_password) do

  #...

  def authenticate!
    admin = Admin.first(:user_name => params["user_name"])

    if admin
      if admin.authenticate(params["password"])
        success!(admin)
      else
        fail!("Invalid password")
      end
    else
      fail!("Invalid username")
    end
  end

end

Also auch hier nichts wildes: Wenn kein Admin mit dem angegebenen Nutzernamen gefunden werden kann, schlägt die Authentifizerung fehl. Ebenso, wenn es einen gibt, aber das Passwort falsch ist (siehe die Implementation von Admin#authenticate oben). Ansonsten wird der Admin als authentifiziert angesehen (success!).

Das ganze wird in meiner Padrino-Haupt-App wie folgt zusammengehalten:

class MyApp < Padrino::Application

  # ...

  enable :sessions

  use Warden::Manager do |manager|
    manager.default_strategies :encrypted_password
    manager.failure_app = MyApp
    manager.serialize_into_session{|admin| admin.id}
    manager.serialize_from_session{|id| Admin.get(id)}
  end

  # ...

  post "/unauthenticated" do
    logger.warn("Authentication failure for #{request.ip}")
    401
  end

  #...

end

Was das tun sollte:

  1. Authentifiziere mit der von mir oben definierten Strategie :encrypted_password
  2. Wenn keine Strategie zum Erfolg führt (da ich nur eine habe: Wenn :encrypted_password fehlschlägt), benutze MyApp als Ziel-Rack-Endpunkt und rufe dort POST /unauthenticated auf (→ Wardens Standardverhalten).
  3. /unauthenticated wiederum gibt einen 401 Unauthenticated zurück und beendet die Abfrage.
  4. Wenn die Authentifizierung erfolgreich ist, speichere die ID des Admin-Objekts in der Session

Was tatsächlich passiert:

  1. Authentifiziere mit der von mir oben definierten Strategie :encrypted_password
  2. Wenn keine Strategie zum Erfolg führt (da ich nur eine habe: Wenn :encrypted_password fehlschlägt), benutze MyApp als Ziel-Rack-Endpunkt und rufe dort POST /unauthenticated auf (→ Wardens Standardverhalten).
  3. /unauthenticated wiederum triggert Warden Authentifizierungsmachenismus, gibt das Request-Objekt weiter und verweist effektiv auf sich selbst
  4. Es gibt einen SystemStackError
  5. Wenn die Authentifizierung erfolgreich ist, speichere die ID des Admin-Objekts in der Session

Der SystemStack-Error sieht dabei etwa so aus:

 ...
  DEBUG -     POST (0.7541ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7546ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7552ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7558ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7564ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7570ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7577ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7842ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7850ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7857ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7863ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7869ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7875ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7881ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7887ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7893ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7899ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7905ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7912ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7918ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7924ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7931ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.7940ms) /unauthenticated - 500 Internal Server Error
  DEBUG -     POST (0.8413ms) /unauthenticated - 500 Internal Server Error
  DEBUG -      GET (0.0073ms) /__sinatra__/500.png - 304 Not Modified

Der Grund dafür liegt hierin, dass Warden den Statuscode 401 von /unauthenticated zum Anlass nimmt, die Authentifizierung noch einmal auszuführen. Dies passiert aber auch nur, wenn die Warden übergebene failure app derselbe Rack-Endpunkt ist, der das ursprüngliche Request behandelt hat.

Es gibt dazu grundsätzlich drei Lösungsansätze:

  1. Die Failure-App auf einen anderen Rack-Endpunkt setzen, der nur diesem einen Zweck dient.
  2. Einen anderen Statuscode als 401 Unauthorized zurückgeben
  3. Warden mitteilen, dass es die neu-Authentifizierung doch bitte unterlassen soll

Die erste Möglichkeit ist vermutlich die eleganteste, aber auch mit meiner Meinung nach unnötigem Code-Aufwand verbunden. Ein anderer Statuscode als 401 ist in meinen Augen ebenfalls nicht tragbar, weil der ja gerade dafür da ist, eine fehlgeschlagene Authentifikation anzuzeigen. Bleibt also noch die letzte Möglichkeit, für die nach ein wenig Suche ich diesen Blogpost fand (der im Übrigen bestätigt, dass das Problem auch unter Sinatra auftritt). Dort wird kommentarlos die Verwendung von custom_failure! vorgeschlagen, was nach kurzem Test auch funktionierte. Da ich aber von Natur aus neugierig bin, habe ich die Warden-Dokumentation herangezogen, die zu dieser Methode folgendes aussagt:

Provides a way to return a 401 without warden defering to the failure app The result is a direct passthrough of your own response

Mit anderen Worten, diese Methode ist genau für diesen einzigen Anwendungszweck überhaupt da. In der Anwendung sieht das dann so aus:

#...
  post "/unauthenticated" do
    logger.warn("Authentication failure for #{request.ip}")
    env["warden"].custom_failure!
    401
  end
#...

Damit gibt die Anwendung dann auch artig 401 Unauthorized zurück, wenn die Authentifizerung fehlschägt und es gibt keinen SystemStackError.

Valete.