Introduction
For years, decades really, the ecosystem of code behind
Catalyst::Plugin::Authentication has been the goto for most people building Perl Catalyst applications that need some sort of login / authentication feature. Recently however I've stopped using it, preferring a lightweight roll my own approach as I ponder what if anything is a good thing to put on CPAN as a replacement or alternative. In this blog I review my issues with this venerable code and show what I'm doing instead and why.
This blog builds on stuff I talked about in earlier blogs that you might be interested in reading first:
- Using Modern Perl Features in your Projects
- Modernize Chained Actions in Perl Catalyst MVC
- Modern Perl Catalyst: Docker Setup
Catalyst::Plugin::Authentication
If you've worked on a Perl Catalyst application, either on the job or just following the tutorial, chances are good you've used Catalyst::Plugin::Authentication and at least one credential and store module to provide basic user site access. Just to clarify, this is an authentication plugin, (gives you a way for someone to prove who they are) which is distinct from authorization (what someone can do). This ecosystem was created with the idea that we could make this task modular so that someone could add in or change the underlying models over time without breaking all the existing code. For example you could have a DBIC backing class for storage and later change it to MongoDB without needing to do anything other than change a few configuration settings. Back in the days when applications were monolithic this level of complexity and 'pluggability' had a lot of appeal.
Lately however, as the authentication world moves more towards tokens and single sign on systems, generally built on OAuth2, I've found it a struggle to graft in Catalyst applications using this system. There's generally a lot of subclasses and hacking and downright confusion. I've seen this in more than one Catalyst application in the field. The last thing you want in an authentication system is a lot of complexity since this is something you need to really understand top to bottom as it's the base of your application security and your client user's trust.
So lately I've stepped back and just built a very minimal system that I can use as we ponder what changes are needed to Catalyst::Plugin::Authentication to make it work in the modern context as well as try to see if we can reduce a bit of the complexity that makes me worried. This example is based of the same codebase I've used for this ongoing series of blogs which you can review, clone and open PRs on to your heart's content.
The Basic API
We'll start by looking at ContactsDemo.pm
which is the Catalyst subclass that defines the base application. The entire file you can see here but I'll just snip the important parts related to the authentication code:
has user => (
is => 'rw',
lazy => 1,
required => 1,
builder => '_get_user',
clearer => 'clear_user',
);
sub _get_user($self) {
my $id = $self->model('Session')->user_id //
return $self->model('Schema::Person')->unauthenticated_user;
my $person = $self->model('Schema::Person')->find_active_user($id) //
$self->logout && die "Bad ID '$id' in session";
return $person;
}
sub persist_user_to_session($self, $user) {
$self->model('Session')->user_id($user->id);
}
sub authenticate($self, $user, @args) {
$self->persist_user_to_session($user)
if my $authenticated = $user->authenticate(@args);
return $authenticated;
}
sub logout($self) {
$self->model('Session')->logout;
$self->clear_user;
}
So there's not much code in my MVP authentication. First I define a user
attribute to hold the user object. I make it lazy so that we don't waste time hitting the DB and creating it if it's not needed. Now lets look at the builder method closer:
sub _get_user($self) {
my $id = $self->model('Session')->user_id //
return $self->model('Schema::Person')->unauthenticated_user;
my $person = $self->model('Schema::Person')->find_active_user($id) //
$self->logout && die "Bad ID '$id' in session";
return $person;
}
I wrap the session hash in a model (we'll look at that in a moment) because I find it too easy for typos to creep into my code doing stuff like $c->session->{usr_id}
and having an object wrapping the session lets us centralize control a bit. So roughly translated this code is saying, "look in the current session for a user_id, if it doesn't exist return an 'unauthenticated user' object, otherwise use that $user_id to find a record in the database and return a DBIC result object based on the Person result source".
This is a bare minimum standard session based authentication setup. The only thing that might be weird to you is that I'm returning that Unauthenticated User object. But I find this approach is a lot nicer than having to say if($c->has_user) { ... }
all over the place. The unauthenticated user object just does the same API as a 'real' user object. This is a type of 'polymorphism' and later on you'll see always having a user object just makes the code a lot neater. It's actually one of the things that lead me to stop using Catalyst::Plugin::Authentication, in order to get it I actually had to write more lines of code than I just did to replace it.
The rest of the code should be pretty straightforward
sub persist_user_to_session($self, $user) {
$self->model('Session')->user_id($user->id);
}
sub authenticate($self, $user, @args) {
$self->persist_user_to_session($user)
if my $authenticated = $user->authenticate(@args);
return $authenticated;
}
Although these two methods could be combined I like the ability to force the session into a particular user so I break out the persist_user_to_session
functionality. I often have administrative users with the ability to 'log in' as a different user, often to help with debugging client problems. As you can see we delegate the meat of actually checking credentials to the user object itself. Remember we always have a user object now. This method sits in the DBIC schema part of the code. You can see the full code here but I'll just copy the method for us to review:
sub authenticate($self, $request) {
my ($username, $password) = $request->get('username', 'password');
my $found = $self->result_source->resultset->find({username=>$username});
if($found && $found->in_storage && $found->check_password($password)) {
%$self = %$found;
return $self;
} else {
$self->errors->add(undef, 'Invalid login credentials');
$self->username($username) if defined($username);
return 0;
}
}
I'm using Valiant with DBIC for validation and error messaging so basically we're just getting the username and password out of the request object, checking against the database and either morphing $self into the $found object or adding a model level error indicating a problem. As in other parts of my code you can see I prefer to return and communicate with objects instead of basic data types. I find this makes for a lot less "checking the return value to figure out what it is" code. Lets take a look at how its actually used in a controller:
sub create :Post('') Via('prepare_build') BodyModel ($self, $c, $user, $bm) {
$c->redirect_to_action($self->post_login_action)
if $c->authenticate($user, $bm);
}
So if $user authenticates with the given request object we redirect to the post login action (basically the user homepage), otherwise the action falls thru and instead you get a login form that displays errors (see that view here).
I promised a quick look at the Session object:
package ContactsDemo::Model::Session;
use Moo;
use ContactsDemo::Syntax;
extends 'Catalyst::Model';
with 'Catalyst::Component::InstancePerContext';
has user_id => (is=>'rw', clearer=>1, predicate=>1);
has contacts_query => (
is=>'rw',
clearer=>1,
predicate=>1);
sub build_per_context_instance($self, $c) {
return bless $c->session, ref($self);
}
sub logout($self) {
$self->clear_user_id;
$self->clear_contacts_query;
}
1;
I'm just using Catalyst::Component::InstancePerContext to wrap the session hash in a lightweight object. Since Moo uses blessed hashes under the hood I get away with this trick (try that core class ;)). So this way I can encapsulate a logout method on the session object that does all the needed logout cleanup, both removing the user id and clearing some session data associated with the contacts page.
You can do the same trick with the stash as well BTW although I try to avoid using the stash at all. But if you really really need it consider encapsulating it in this manner, you'll be grateful later.
Last Thoughts
I recognize "don't use CPAN; roll your own" is likely controversial and might surprise some of my peers who know me and know I almost always favor existing projects over bespoke code. So hey, maybe I'm wrong here. Feel feel in the comments to show me!
Top comments (0)