2012 Feed

Authentication III: Return Of The Middleware

OX - 2012-12-22

Our Story So Far

We've seen how to use middleware for simple authentication as well as a more complicated model-based authentication and authorization scheme. For most applications the former is far too simplistic, and if you're working on a reasonably complicated project, the latter has one large flaw: you have to remember to manually check for authentication and authorization, in every method that requires them. Eventually somebody will forget to add that one critical line when they're writing a new method, and then you have a problem.

One potential fix, since OX is really just Moose under the covers, is to use an around method modifier in your controller classes. While that approach can be made to work, it's not the best solution. Instead, let's return to middleware -- custom middleware, to be precise.

A More Elegant Approach, For A More Civilized Application

We'll use the same OXauth demo application as the earlier, model-based authentication example, but change it to use a custom middleware. On the application level, the change is pretty minor, we just add an additional wrap_if statement to the router block, that applies the authentication middleware if the request path matches /admin. We also add a name attribute for the login action -- we'll see how and where that gets used shortly.

(The code for this version of the application is available in the OXauth repo in the middleware-auth branch.)


1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 
9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
21: 
22: 
23: 
24: 
25: 
26: 
27: 
28: 
29: 
30: 
31: 
32: 
33: 
34: 
35: 
36: 
37: 
38: 
39: 
40: 
41: 
42: 
43: 
44: 
45: 
46: 
47: 
48: 
49: 
50: 
51: 
52: 
53: 
54: 
55: 
56: 
57: 
58: 
59: 
60: 
61: 
62: 
63: 

 

package OXauth;
# ABSTRACT: OX advent auth example
use OX;
use 5.010;

use OXauth::Schema;

has connect_info => ( is => 'ro', isa => 'ArrayRef' , required => 1 );

has model => (
  is => 'ro' ,
  isa => 'OXauth::Schema' ,
  dependencies => [ 'connect_info' ] ,
  lifecycle => 'Singleton' ,
  block => sub {
    OXauth::Schema->connect( @{ shift->param( 'connect_info' )} )
  } ,
);

has cache_dir => ( is => 'ro' , isa => 'Str' , required => 1 );
has template_root => ( is => 'ro' , isa => 'Str' , required => 1 );

has view => (
  is => 'ro' ,
  isa => 'Text::Xslate' ,
  dependencies => {
    cache_dir => 'cache_dir' ,
    path => 'template_root' ,
  },
);

has admin_controller => (
  is => 'ro' ,
  isa => 'OXauth::Controller::Admin' ,
  infer => 1 ,
);

has auth_controller => (
  is => 'ro' ,
  isa => 'OXauth::Controller::Auth' ,
  infer => 1 ,
);

has root_controller => (
  is => 'ro',
  isa => 'OXauth::Controller::Root' ,
  infer => 1 ,
);

router as {
  wrap 'Plack::Middleware::Session' => ( store => literal( 'File' ));

  wrap_if( sub { $_[0]->{PATH_INFO} =~ m{^/admin} },
           'OXauth::Middleware::Auth' => ( model => 'model' ));

  route '/' => 'root_controller.index';
  route '/login' => 'auth_controller.login' , ( name => 'login' );
  route '/logout' => 'auth_controller.logout';
  route '/admin' => 'admin_controller.index';
};

__PACKAGE__->meta->make_immutable;
1;

 

That wrap_if line is given the PSGI request environment as an argument. We look to see if the path of the current request matches the part of the application that requires authentication. If that inline sub returns a true value, the middleware will be included. Any arguments to the middleware will be resolved as Bread::Board services -- so this middleware will receive the model service.

All the real action happens in the custom middleware, so let's look at that now.

The Middleware Is Strong In This One

"Custom middleware" sounds pretty daunting, but in the end it's not. You write a MooseX::NonMoose class that extends Plack::Middleware, and provide a call callback method, which will get passed the PSGI environment. Writing the middleware as a Moose class means we can define attributes (model, in the case below) which are provided via OX's use of Bread::Board service resolution.


1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 
9: 
10: 
11: 
12: 
13: 
14: 
15: 
16: 
17: 
18: 
19: 
20: 
21: 
22: 
23: 
24: 
25: 
26: 
27: 
28: 
29: 
30: 
31: 
32: 
33: 
34: 
35: 
36: 
37: 
38: 
39: 

 

package OXauth::Middleware::Auth;
use Moose;
use MooseX::NonMoose;
extends 'Plack::Middleware';

use HTTP::Throwable::Factory 'http_exception';
use OX::Request;

has model => (
  is => 'ro',
  isa => 'OXauth::Schema' ,
  required => 1,
  handles => [ qw/ load_user / ] ,
);

sub call {
  my( $self , $env ) = @_;

  my $req = OX::Request->new(env => $env);

  my $login_url = $req->uri_for('login');

  # load the user data if there's a user_id set in the session
  if ( my $id = $req->session->{user_id} ) {
    $req->session->{user} = $self->load_user( $id );
  }

  # if we have a user or if we're trying to login, carry on
  if ( $req->session->{user} || $req->uri->path eq $login_url) {
    return $self->app->($env);
  }
  # otherwise redirect to the login url
  else {
    return $req->new_response->redirect( $login_url );
  }
}

__PACKAGE__->meta->make_immutable;
1;

 

The line that uses the uri_for method is the reason why we added a name attribute to the /login route -- that allows us to have a consistent stable identifier for that routing even if the path part or the controller method that it invokes is renamed. (See the uri_for entry earlier in the calendar for more information.)

The logic here is very simple -- if there's a user_id key set in the session, we load up that user from the database. If there's a user in the session or if we're trying to load the login URL, we call the wrapped PSGI application instance, passing it the environment, and return the result -- which will allow for further routing or middleware application to happen.

If we don't have a user and aren't trying to login, we instead generate a redirect to the login URL.

All the code for the login and logout methods in OXauth::Controller::Auth remains exactly the same as in the earlier example (with one tiny addition -- the logout method clears the user key in the session hash in addition to user_id.)

It's A Trap

The more observant may have noticed that the middleware code didn't actually need to check for whether the path matched the login URL -- because the middleware was only wrapped when the path matched /admin, and the login URL (at /login) would never be the path while the middleware was executing. We can easily make both checks necessary by adjusting the router block:


1: 
2: 
3: 
4: 
5: 
6: 
7: 
8: 
9: 
10: 
11: 

 

router as {
  wrap 'Plack::Middleware::Session' => ( store => literal( 'File' ));

  wrap_if( sub { $_[0]->{PATH_INFO} =~ m{^/admin} },
           'OXauth::Middleware::Auth' => ( model => 'model' ));

  route '/' => 'root_controller.index';
  route '/admin/login' => 'auth_controller.login' , ( name => 'login' );
  route '/admin/logout' => 'auth_controller.logout';
  route '/admin' => 'admin_controller.index';
};

 

Since you're undoubtably using uri_for in your templates as well, those will also automatically reflect this change, and the application will continue to function identically, with no other code changes needed. This should emphasize how the flexible mapping between URL routes and methods called on resolved services, along with the name-based lookups available via the uri_for method, makes it trivial to reorganize your URL organization scheme or your code, independently of each other.

I Find Your Lack Of Authorization Disturbing

The earlier model-based example included an authorization component which this example lacks. Extending the middleware to support that would not be difficult, however. One approach would be to have a 'config' service that defines which roles have access to which paths, and then add that service as an additional parameter to the middleware, which could then add in a permissions check once a user was loaded. At that point it would probably be easier to include your custom authentication middleware unconditionally (i.e., via a normal wrap statement) and use the config service to determine both whether authentication was needed, and if so, what authorizations were also required.

Gravatar Image This article contributed by: John SJ Anderson <john.anderson@iinteractive.com>