Kerberos and Zend Framework Applications

Let's say you want to enable single sign-on (via Kerberos) for an application written in Zend Framework.

The simplest solution is to just use mod_auth_kerb and put HTTP authentication in front of the whole site. This will work, but it has some drawbacks.

  1. You impose the HTTP authentication and Kerberos negotiation overhead on every single request (CSS and images included).
  2. Your application has no idea who's accessing it, making authorization impossible.
  3. You might want at least part of the site to be visible to unauthenticated users. At the very least, you'll probably want to explain who's allowed to use it and how.
  4. Related to the above, you may want to provide an attractive and helpful login interface for users if Kerberos negotiation fails (instead of the browser's generic login dialog).

The application I was dealing with allows anyone to view information and should require authorization only to make changes, so the simple approach was out. There were a lot of challenges with this setup, but here's the end result:

  • Only one URL in the entire application triggers Kerberos negotiation
  • If Kerberos negotiation fails, a login page (from the application) is presented to the user.

This requires some changes to your virtual host's configuration, as well as the application.

Virtual Host Setup

Determine the location for your authentication action. I used https://webserver/authenticate. I didn't call it "login" (although it handles that function) because, hey, that's plan B right? You shouldn't have to log in at all. You'll need something like this in your application's virtual host:

<Location /authenticate>
  <Limit GET>
    ## Kerberos Authentication
    AuthType Kerberos
    AuthName "My Zend Framework Application"
    KrbMethodNegotiate On
    KrbMethodK5Passwd Off
    Krb5KeyTab /etc/httpd/httpd.keytab
    Require valid-user
  </Limit>
  ErrorDocument 401 /fallback_login
</Location>

Some Explanation

First, we limit Kerberos negotiation to GET requests because if a POST comes through, it'll be because the user is manually entering credentials on your pretty login form. We don't want to require that they be authenticated at the HTTP level to submit this form (because if that was going to work, they shouldn't have seen the form in the first place).

Next, we turn KrbMethodK5Passwd off to prevent the browser from prompting the user for credentials if KrbMethodNegotiate fails.

Finally, we pervert the ErrorDocument directive. Where I put /fallback_login, I could have put /who_would_cross_the_bridge_of_death_must_answer_me_these_questions_three. It's a non-existent path. You can set it to anything you want, except for the path in the <Location> directive.

Let me explain what happens when /authenticate is requested. A 401 will be sent to the browser on the initial request no matter what. If you have a ticket, the browser will say "Oh, sure. Here's my identity." If you don't, the browser will say "Sorry, can't help you." If your browser doesn't support Kerberos, it'll say "What? Quit making shit up." In every case, whether you see it or not, content is being sent to your browser. By default, this is a generic "Authorization Required" page. By telling Apache to use a custom ErrorDocument for 401 errors, you are mostly just preventing it from using the built-in error.

But here's the thing: This isn't a redirect. It's all part of the same HTTP request, so the location never changes and your browser will never request /fallback_login. If you're at all familiar with Zend Framework, you probably know where this is going. Zend Framework (like pretty much every web application framework) uses mod_rewrite to run everything through a single script. That script then decides what to do based on the request URI. As a result, whatever lives at the /authenticate location needs to be able to handle both Kerberos and traditional logins.

And just so you don't waste your timeā€¦ I originally had a separate "login" action and attempted to redirect users to it if no Kerberos identity could be found. Don't do this. You have to let the script execution finish normally. Remember, the identity is never present in the initial request. If you redirect for this reason, the browser will get a 302 instead of a 401. The 401 is needed to challenge it for a Kerberos ticket. Without that, it will never provide one, and redirects to the login page will become effectively unconditional.

The Application

Routing

A quick note on the location above. This is handled by an action called "authenticate" in "AuthnController", but I wanted something nicer looking than /authn/authenticate. To accomplish that, I add this to Bootstrap.php:

<?php
protected function _initRoutes()
{
  $front = Zend_Controller_Front::getInstance();
  $router = $front->getRouter();
  $router->addConfig(
    new Zend_Config_Ini(
      APPLICATION_PATH . '/configs/application.ini',
      APPLICATION_ENV ),
    'routes' );
}
?>

And then in application.ini:

routes.authenticate.route = "authenticate"
routes.authenticate.defaults.controller = "authn"
routes.authenticate.defaults.action = "authenticate"

The Authentication Adapter

For reasons that should be obvious, you'll want to incorporate this into the standard Zend_Auth way of doing things. In my case, I was updating an application that was already using Zend_Auth. On the other hand, you might want to disable this one day and simply use a login page. Either way, it's a lot easier to just switch out the authentication adapter than it is to find and modify all the various parts of your application that rely on determining if a user is logged in.

Looking at the documentation, I saw that there was a Zend_Auth_Http adapter, but it wasn't clear to me what it was for. Is it a way to use HTTP auth on a remote site to authenticate users of your app? Is it a way to see if authentication has already taken place on the local web server at the HTTP layer? Is it a replacement for HTTP auth at the web server level? I don't know, but it didn't suit my needs, so I ended up writing a new adapter1.

<?php
class Auth_Adapter_Kerberos implements Zend_Auth_Adapter_Interface
{
  public function __construct()
  {
    // check HTTP auth variables
    $user_principal = @$_SERVER['REMOTE_USER'] ? $_SERVER['REMOTE_USER'] : @$_SERVER['REDIRECT_REMOTE_USER'];
    if ( $user_principal !== null ) {
      // split the username from the realm
      $username = strstr( $user_principal, '@', true );
      $this->setUsername($username);
    }
  }

  public function authenticate()
  {
    $messages = array();
    $messages[0] = ''; // reserved
    $messages[1] = ''; // reserved
    $username = $this->_username;
    if (!$username) {
      // no information from Kerberos negotiation
      $code = Zend_Auth_Result::FAILURE_IDENTITY_NOT_FOUND;
      $messages[0] = 'A username is required';
      return new Zend_Auth_Result($code, '', $messages);
    } else {
      $messages[] = "$username authentication successful";
      return new Zend_Auth_Result(Zend_Auth_Result::SUCCESS, $username, $messages);
    }
  }
}
?>

The name is misleading. The basic process here could apply to any form of authentication at the HTTP layer (anything that sets REMOTE_USER), not just Kerberos, but there was already an adapter with a generic "HTTP" name, and I didn't want to confuse them.

The Authentication Controller

The whole thing is below, but here's an overview of what the authenticate action is responsible for.

  • Require SSL (redirect to the same URL, but use HTTPS instead).
  • If it's a GET request, try the Kerberos method.
    • If it succeeds, redirect.
    • If Kerberos negotiation fails, present the login form.
  • If it's a POST request, check the credentials with another method. (I use Zend_Auth_Adapter_Ldap)
    • If it succeeds, redirect.
    • If authentication fails, show the form again with an error.

There are some things this depends on that I won't bother documenting because I think they're pretty well-known or easy to figure out.

  • The view script
  • Creating a login form
  • Storing things in the registry
  • Logging
  • Storing the URL of the current page prior to sending users through the authentication process, so they can be sent back after

The code:

<?php
class AuthnController extends Zend_Controller_Action
{
  public function init()
  {
    $this->_redirector = $this->_helper->getHelper( 'Redirector' );
    $this->activity = Model_MyAppHelper::getActivityLog();
  }

  public function authenticateAction()
  {
    // allow a user to log in (require SSL)
    if ( isset( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] == "on" ) {
      // SSL enabled, proceed
      if ( Zend_Auth::getInstance()->hasIdentity() ) {
        // already logged in
        $this->_redirect( "/" );
      }

      $form = new Form_LoginForm();
      if ( $this->getRequest()->isPost() ) {
        // manual login attempt
        if ( $form->isValid( $this->_request->getPost() ) ) {
          // attempt authentication via LDAP
          $auth = Zend_Auth::getInstance();
          $settings = Zend_Registry::get( 'ldap' );
          $user = $form->getValue( 'username' );
          $this->activity->setEventItem( 'username', $user );
          $pass = $form->getValue( 'password' );
          $auth_adapter = new Zend_Auth_Adapter_Ldap( $settings->ldap->toArray(), $user, $pass );
          $result = $auth->authenticate( $auth_adapter );
          if ( $result->isValid() ) {
            // successful login, store user info
            $user_details = $auth_adapter->getAccountObject();
            $auth->getStorage()->write( $user_details );
            $this->activity->info( "manual login successful" );
            $sess_hist = new Zend_Session_Namespace('history');
            if ( isset( $sess_hist->postauth_destination ) ) {
              $destination = $sess_hist->postauth_destination;
              unset( $sess_hist->postauth_destination );
            } else {
              $destination = "/";
            }
            $this->_redirect( $destination );
          } else {
            $heading = "Manual Login";
            $this->view->headTitle($heading, 'APPEND');
            $this->view->heading = $heading;
            $this->view->authError = "Incorrect username or password";
            $this->view->form = $form;
            $this->activity->info( "manual login failed" );
          }
        }
      } else {
        // attempt identification via Keberos
        $auth = Zend_Auth::getInstance();
        $auth_adapter = new Auth_Adapter_Kerberos();
        $result = $auth->authenticate( $auth_adapter );
        switch ( $result->getCode() ) {
          case Zend_Auth_Result::FAILURE_IDENTITY_NOT_FOUND:
            // Kerberos identity unavailable from the browser
            $this->activity->info( "Attempting Kerberos negotiation with browser" );
            $this->view->text = "<p>Your identity couldn't be determined automatically. If this is unexpected, you might want to run <code>kinit</code> and refresh this page. If you have no idea what that means, just enter your username and password below.</p>";
            $heading = "Manual Login";
            $this->view->headTitle($heading, 'APPEND');
            $this->view->heading = $heading;
            $this->view->form = $form;
          break;
          case Zend_Auth_Result::SUCCESS:
            // successful login, store user info
            $auth->getStorage()->write( $username );
            $this->activity->setEventItem( 'username', $username );
            $this->activity->info( "Kerberos negotiation successful" );
            $sess_hist = new Zend_Session_Namespace('history');
            if ( isset( $sess_hist->postauth_destination ) ) {
              $destination = $sess_hist->postauth_destination;
              unset( $sess_hist->postauth_destination );
            } else {
              $destination = "/";
            }
            $this->_redirect( $destination );
          break;
          default:
            $this->activity->info( "unknown authentication error" );
            $heading = "Login";
            $this->view->headTitle($heading, 'APPEND');
            $this->view->heading = $heading;
            $this->view->text = "<p>An unknown error occured. Can you imagine a more useless message?</p>";
          break;
        }
      }
    } else {
      // redirect to HTTPS
      $this->activity->info( "login attempted via HTTP" );
      $this->_redirector->gotoUrl( "https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
    }
  }

  public function logoutAction()
  {
    $auth = Zend_Auth::getInstance();
    if ( $auth->hasIdentity() ) {
      $this->activity->setEventItem( 'username', $auth->getStorage()->read()->uid );
      $this->activity->info( "logout" );
    } else {
      $this->activity->info( "logout attempted for no one" );
    }
    $auth->clearIdentity();
    $this->_redirect( "/" );
  }
}
?>

  1. The adapter shown here is a filthy lie. The one I actually wrote and use extends Zend_Auth_Adapter_Ldap because I wanted to get additional details from LDAP once I knew the username. If the one above doesn't work, it's because I've never actually tried it. 

blog comments powered by Disqus