Login, Authorization and User Administration

This tutorial will describe the latest tech in Catalyst authentication and authorization and describe a real-world user administration system.

To follow along, first check out the sample code for chapter 4 of the Catalyst tutorial:

    svn co http://dev.catalystframework.org/repos/Catalyst/trunk/examples/Tutorial/Final/Chapter04

To get the complete working source for this article, download this file.

The Users Schema

In users.sql we'll put the tables for holding the user information of our app.

If you're following along on PostgreSQL replace INTEGER PRIMARY KEY with BIGSERIAL PRIMARY KEY.

    DROP TABLE IF EXISTS users;
    DROP TABLE IF EXISTS roles;
    DROP TABLE IF EXISTS user_roles;

    CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        active CHAR(1) NOT NULL,
        username TEXT NOT NULL UNIQUE,
        password TEXT NOT NULL,
        password_expires TIMESTAMP,
        name TEXT NOT NULL,
        email_address TEXT NOT NULL,
        phone_number TEXT,
        mail_address TEXT
    );

    CREATE TABLE roles (
        id INTEGER PRIMARY KEY,
        name TEXT NOT NULL UNIQUE
    );

    CREATE TABLE user_roles (
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE,
        role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE,
        PRIMARY KEY (user_id, role_id)
    );

    INSERT INTO users (username, active, name, email_address, password) VALUES (
        'admin', 'Y', 'Administrator', 'admin@myapp.org', 'dummy'
    );
    INSERT INTO roles (name) VALUES ('admin');
    INSERT INTO roles (name) VALUES ('can_edit');
    INSERT INTO user_roles (user_id, role_id) VALUES (
        (SELECT id FROM users WHERE username = 'admin'),
        (SELECT id FROM roles WHERE name     = 'admin')
    );

Load the schema into the db:

    sqlite3 myapp.db < users.sql

Password Hashing

We will use DBIx::Class::PassphraseColumn and Authen::Passphrase::BlowfishCrypt for handling the password hash. This is currently the most secure hashing method.

It is a good idea to add dependencies for your application to the Makefile.PL, as follows:

    requires 'DBIx::Class::PassphraseColumn';
    requires 'Authen::Passphrase::BlowfishCrypt';

Then when you deploy the app, you can simply do:

    perl Makefile.PL
    make listdeps | cpanm

Or cpanm -n to skip tests.

First regenerate the DBIx::Class schema with our new tables and this component (make sure you have the latest CPAN version of DBIx::Class::Schema::Loader:)

    perl script/myapp_create.pl model DB DBIC::Schema MyApp::Schema \
        create=static components=TimeStamp,PassphraseColumn dbi:SQLite:myapp.db

Now edit lib/MyApp/Schema/Result/User.pm and below the DO NOT MODIFY line add the following:

    __PACKAGE__->add_columns(
        '+password' => {
            passphrase       => 'rfc2307',
            passphrase_class => 'BlowfishCrypt',
            passphrase_args  => {
                cost        => 14,
                salt_random => 20,
            },
            passphrase_check_method => 'check_password',
        }
    );

If your DBIx::Class::Schema::Loader did not generate a many_to_many to roles, you will also need to add the following:

    __PACKAGE__->many_to_many("roles", "user_roles", "role");

Configuring Catalyst Authentication

We will use CatalystX::SimpleLogin as the entry point to the auth system.

You will need to install a memcached server, on Debian run the following:

    sudo aptitude install memcached

You will need the following modules installed: Catalyst::Plugin::Session, Catalyst::Plugin::Session::Store::Memcached, Catalyst::Plugin::Session::State::Cookie, Catalyst::Plugin::Authentication, Catalyst::Authentication::Store::DBIx::Class, Catalyst::Plugin::Authorization::Roles and CatalystX::SimpleLogin.

Now edit lib/MyApp.pm and change the use Catalyst line to:

    use Catalyst qw/
        -Debug
        ConfigLoader
        Static::Simple
        Session
        Session::Store::Memcached
        Session::State::Cookie
        Authentication
        Authorization::Roles
        +CatalystX::SimpleLogin
    /;

Above the __PACKAGE__->setup() call, put the following configuration:

    __PACKAGE__->config(
       authentication => {
          default_realm => 'users',
          realms        => {
             users => {
                credential => {
                   class          => 'Password',
                   password_field => 'password',
                   password_type  => 'self_check'
                },
                store => {
                   class         => 'DBIx::Class',
                   user_model    => 'DB::User',
                   role_relation => 'roles',
                   role_field    => 'name',
                }
             }
          },
       },
       'Controller::Login' => {
           traits => ['-RenderAsTTTemplate'],
           login_form_args => {
               authenticate_args => { active => 'Y' },
           },
       },
    );

Now we need a login template:

    mkdir root/src/login
    touch root/src/login/login.tt2

Place the following into the root/src/login/login.tt2 file:

    [% META title = 'Welcome to MyApp: Please Log In' %]
    <div>
        [% FOR field IN login_form.error_fields %]
            [% FOR error IN field.errors %]
                <p><span style="color: red;">[% field.label _ ': ' _ error %]</span></p>
            [% END %]
        [% END %]
    </div>

    <div>
        <form id="login_form" method="post" action="[% c.req.uri %]">
            <fieldset style="border: 0;">
                <table>
                    <tr>
                        <td><label class="label" for="username">Username:</label></td>
                        <td><input type="text" name="username" value="" /></td>
                    </tr>
                    <tr>
                        <td><label class="label" for="password">Password:</label></td>
                        <td><input type="password" name="password" value="" /></td>
                    </tr>
                    <tr><td><input type="submit" name="submit" value="Login" /></td></tr>
                </table>
            </fieldset>
        </form>
    </div>

Now we need the auth to protect the app. Replace the contents of lib/MyApp/Controller/Root.pm with the following:

    package MyApp::Controller::Root;
    use Moose;
    use namespace::autoclean;

    BEGIN { extends 'Catalyst::Controller' }

    __PACKAGE__->config(namespace => '');

    sub base : Chained('/login/required') PathPart('') CaptureArgs(0) {}

    sub home : Chained('/base') PathPart('') Args(0) {
        my ($self, $c) = @_;

        $c->res->redirect($c->uri_for('/books/list'));
    }

    sub default : Chained('/base') PathPart('') Args {
        my ($self, $c) = @_;
        $c->res->body('Page not found');
        $c->res->status(404);
    }

    sub end : ActionClass('RenderView') {}

    __PACKAGE__->meta->make_immutable;

    1;

Now make sure all actions in the app chain from /base. Replace the Books controller in lib/MyApp/Controller/Books.pm with the following:

    package MyApp::Controller::Books;
    use Moose;
    use namespace::autoclean;

    BEGIN {extends 'Catalyst::Controller'; }

    sub base : Chained('/base') PathPrefix CaptureArgs(0) {}

    sub list : Chained('base') PathPart('list') Args(0) {
        my ($self, $c) = @_;

        $c->stash(books => [ $c->model('DB::Book')->all ]);
    }

    sub url_create : Chained('base') PathPart('url_create') Args(3) {
        my ($self, $c, $title, $rating, $author_id) = @_;

        my $book = $c->model('DB::Book')->create({
            title  => $title,
            rating => $rating
        });

        $book->add_to_book_authors({ author_id => $author_id });

        $c->stash(
            book     => $book,
            template => 'books/create_done.tt2'
        );

        $c->response->header('Cache-Control' => 'no-cache');
    }




    sub form_create : Chained('base') PathPart('form_create') Args(0) {
        my ($self, $c) = @_;

        $c->stash(template => 'books/form_create.tt2');
    }




    sub form_create_do : Chained('base') PathPart('form_create_do') Args(0) {
        my ($self, $c) = @_;

        my $title     = $c->request->params->{title}     || 'N/A';
        my $rating    = $c->request->params->{rating}    || 'N/A';
        my $author_id = $c->request->params->{author_id} || '1';

        my $book = $c->model('DB::Book')->create({
            title   => $title,
            rating  => $rating,
        });

        $book->add_to_book_authors({author_id => $author_id});

        $c->stash(
            book     => $book,
            template => 'books/create_done.tt2'
        );
    }

    sub object : Chained('base') PathPart('id') CaptureArgs(1) {
        my ($self, $c, $id) = @_;

        $c->stash(object => $c->model('DB::Book')->find($id));

        die "Book $id not found!" if !$c->stash->{object};
    }




    sub delete : Chained('object') PathPart('delete') Args(0) {
        my ($self, $c) = @_;

        $c->stash->{object}->delete;

        $c->res->redirect($c->uri_for($self->action_for('list'),
            {status_msg => "Book deleted."}));
    }




    sub list_recent : Chained('base') PathPart('list_recent') Args(1) {
        my ($self, $c, $mins) = @_;

        $c->stash(books => [$c->model('DB::Book')
                                ->created_after(DateTime->now->subtract(minutes => $mins))]);

        $c->stash(template => 'books/list.tt2');
    }




    sub list_recent_tcp : Chained('base') PathPart('list_recent_tcp') Args(1) {
        my ($self, $c, $mins) = @_;

        $c->stash(books => [
                $c->model('DB::Book')
                    ->created_after(DateTime->now->subtract(minutes => $mins))
                    ->title_like('TCP')
            ]);

        $c->stash(template => 'books/list.tt2');
    }

    __PACKAGE__->meta->make_immutable;

    1;

Now we need to set the admin password so we can log in and try it out, in script/set_admin_password.pl place the following:

    #!/usr/bin/env perl

    use strict;
    use warnings;
    use lib 'lib';

    BEGIN { $ENV{CATALYST_DEBUG} = 0 }

    use MyApp;
    use DateTime;

    my $admin = MyApp->model('DB::User')->search({ username => 'admin' })
        ->single;

    $admin->update({ password => 'admin', password_expires => DateTime->now });

Now run perl script/set_admin_password.pl and the password should be set.

Now run perl script/myapp_server.pl, go to the server in your web browser and it should ask you to log in. Log in as admin/admin, and it should take you to the books list.

Authorization

Let's protect our edit and delete actions for books by allowing only users with the can_edit or admin roles.

You will need the following modules: Catalyst::Controller::ActionRole and Catalyst::ActionRole::ACL.

Change the extends line in the Books controller to:

    BEGIN { extends 'Catalyst::Controller::ActionRole'; }

Add the edit chain bases:

    sub edit : Chained('base') PathPart('') CaptureArgs(0) Does('ACL') AllowedRole('admin') AllowedRole('can_edit') ACLDetachTo('denied') {}

    sub edit_object : Chained('object') PathPart('') CaptureArgs(0) Does('ACL') AllowedRole('admin') AllowedRole('can_edit') ACLDetachTo('denied') {}

Now make the actions url_create, form_create, form_create_do chain from edit and delete chain from edit_object.

Finally, add a denied action:

    sub denied : Private {
        my ($self, $c) = @_;

        $c->res->redirect($c->uri_for($self->action_for('list'),
            {status_msg => "Access Denied"}));
    }

Now try removing your roles:

    % sqlite3 myapp.db
    sqlite> DELETE FROM user_roles;
    sqlite> .q

Restart the server, log back into the app, and try hitting a delete link. You should get the message Access Denied.

We should really hide the delete link, to do so edit root/src/books/list.tt2. and change the Delete link code to:

    [% IF c.check_any_user_role('can_edit', 'admin') %]
    <td>
      [% # Add a link to delete a book %]
      <a href="[%
        c.uri_for(c.controller.action_for('delete'), [book.id]) %]">Delete</a>
    </td>
    [% END %]

Now restart the server and go back to the book list, the delete link should no longer appear.

Now recreate the role mapping:

    % sqlite3 myapp.db
    sqlite> INSERT INTO user_roles (user_id, role_id) VALUES (
       ...>     (SELECT id FROM users WHERE username = 'admin'),
       ...>     (SELECT id FROM roles WHERE name     = 'admin')
       ...> );
    sqlite> .q

Password Expiry

For the next section you will need the modules HTML::FormHandler and Method::Signatures::Simple.

Create a skeleton User controller with a change_password action in lib/MyApp/Controller/User.pm:

    package MyApp::Controller::User;
    use Moose;
    use namespace::autoclean;
    use MyApp::Form::ChangePassword ();

    BEGIN { extends 'Catalyst::Controller::ActionRole'; }

    sub base : Chained('/base') PathPrefix CaptureArgs(0) {}

    sub admin : Chained('base') PathPart('') CaptureArgs(0) Does('ACL') RequiresRole('admin') ACLDetachTo('denied') {}

    sub change_password : Chained('base') PathPart('change_password') Args(0) {
        my ($self, $c) = @_;

        my $form = MyApp::Form::ChangePassword->new;

        $c->stash(form => $form);

        return unless $form->process(
            user   => $c->user,
            params => $c->req->body_parameters,
        );

        $c->user->update({
            password         => $form->field('new_password')->value,
            password_expires => undef,
        });

        $c->res->redirect($c->uri_for('/books/list', {
            status_msg => 'Password changed successfully'
        }));
    }

    sub denied : Private {
        my ($self, $c) = @_;

        $c->res->redirect($c->uri_for('/books/list', {
            status_msg => "Access Denied"
        }));
    }

    __PACKAGE__->meta->make_immutable;

    1;

And the form class in lib/MyApp/Form/ChangePassword.pm:

    package MyApp::Form::ChangePassword;

    use HTML::FormHandler::Moose;
    extends 'HTML::FormHandler';
    use namespace::autoclean;
    use Method::Signatures::Simple;

    has user => (is => 'rw');

    has_field 'current_password' => (
       type     => 'Password',
       label    => 'Current Password',
       required => 1,
    );

    method validate_current_password($field) {
        $field->add_error('Incorrect password')
            if not $self->user->check_password($field->value);
    }

    has_field 'new_password' => (
        type      => 'Password',
        label     => 'New Password',
        required  => 1,
        minlength => 5,
    );

    after validate => method {
        if ($self->field('new_password')->value eq
                $self->field('current_password')->value )
        {
            $self->field('new_password')
                ->add_error('Must be different from current password');
        }
    };

    has_field 'new_password_conf' => (
       type           => 'PasswordConf',
       label          => 'New Password (again)',
       password_field => 'new_password',
       required       => 1,
       minlength      => 5,
    );

    has_field submit => (type => 'Submit', value => 'Change');

    __PACKAGE__->meta->make_immutable;

    1;

And the template in root/src/user/change_password.tt2:

    [% META title = 'MyApp: Change Password' %]

    <div>
    <form name="[% form.name %]" action="[% c.req.uri %]" method="post">

    [% FOR field IN form.error_fields %]
        [% FOR error IN field.errors %]
            <p><span style="color: red;">[% field.label _ ': ' _ error %]</span></p>
        [% END %]
    [% END %]

    <fieldset style="border: 0;">
    <table>
    [% FOREACH field_name = ['current_password', 'new_password', 'new_password_conf'] %]
    <tr>
    [% f = form.field(field_name) %]
    <td><label for="[% f.name %]">[% f.label %]:</label></td>
    <td><input type="password" name="[% f.name %]" id="[% f.name %]" value="[% f.fif %]"></td>
    </tr>
    [% END %]
    <tr><td><input type="submit" name="submit" value="Change" /></td></tr>
    </fieldset>
    </table>
    </form>
    </div>

Now to enforce password expiry, we need to modify the base action in the root controller, edit lib/MyApp/Controller/Root.pm and replace the first part with the following:

    package MyApp::Controller::Root;
    use Moose;
    use namespace::autoclean;
    use DateTime;

    BEGIN { extends 'Catalyst::Controller' }

    __PACKAGE__->config(namespace => '');

    sub base : Chained('/login/required') PathPart('') CaptureArgs(0) {
        my ($self, $c) = @_;

        if ($c->action ne $c->controller('User')->action_for('change_password')
            && $c->user_exists
            && $c->user->password_expires
            && $c->user->password_expires <= DateTime->now)
        {
            $c->res->redirect($c->uri_for('/user/change_password', {
                status_msg => 'Password Expired'
            }));
            $c->detach;
        }
    }

Now restart the server and open the site in your web browser, you will be asked to change your password then taken to the book list.

User Profile

Now we'll create an editable profile page.

You will need the module HTML::FormHandler::Model::DBIC.

First, lets add a couple navigation links to the wrapper, edit root/src/wrapper.tt2 and change the menu to the following:

    <div id="bodyblock">
    <div id="menu">
        <ul>
            <li><strong>Navigation:</strong></li>
            <li><a href="[% c.uri_for('/books/list') %]">Home</a></li>
            <li><a href="[% c.uri_for('/user/profile') %]">Profile</a></li>
            <li><a href="[% c.uri_for('/user/change_password') %]">Change Password</a></li>
            [% IF c.check_user_roles('admin') %]
            <li><a href="[% c.uri_for('/user/list') %]">Admin</a></li>
            [% END %]
            <li><a href="[% c.uri_for('/logout') %]">Logout</a></li>
        </ul>
    </div><!-- end menu -->

Lets make a form class for the profile, create a lib/MyApp/Form/UserProfile.pm with the following:

    package MyApp::Form::UserProfile;

    use HTML::FormHandler::Moose;
    extends 'HTML::FormHandler::Model::DBIC';
    use namespace::autoclean;

    has '+item_class' => (default => 'User');

    has_field 'name'          => ( type => 'Text',  required => 1 );
    has_field 'email_address' => ( type => 'Email', required => 1 );
    has_field 'phone_number'  => ( type => 'Text' );
    has_field 'mail_address'  => ( type => 'Text' );

    has_field submit => (
       type  => 'Submit',
       value => 'Update'
    );

    __PACKAGE__->meta->make_immutable;

    1;

Then we'll add a profile action to the User controller. At the top add:

    use MyApp::Form::UserProfile    ();

Then add the action:

    sub profile : Chained('base') PathPart('profile') Args(0) {
        my ($self, $c) = @_;

        my $form = MyApp::Form::UserProfile->new;

        $c->stash(form => $form);

        return unless $form->process(
            schema  => $c->model('DB')->schema,
            item_id => $c->user->id,
            params  => $c->req->body_parameters,
        );

        $c->res->redirect($c->uri_for('/books/list', {
            status_msg => 'Profile Updated'
        }));
    }

Add the template in root/src/user/profile.tt2:

    [% META title = 'MyApp: User Profile' %]

    <div>
    <form name="[% form.name %]" action="[% c.req.uri %]" method="post">

    [% FOR field IN form.error_fields %]
        [% FOR error IN field.errors %]
            <p><span style="color: red;">[% field.label _ ': ' _ error %]</span></p>
        [% END %]
    [% END %]

    <fieldset style="border: 0;">
    <table>
    [% FOREACH field_name = ['name', 'email_address',
                             'phone_number', 'mail_address'] %]
    <tr>
    [% f = form.field(field_name) %]
    <td><label for="[% f.name %]">[% f.label %]:</label></td>
    <td><input type="text" size=30 name="[% f.name %]" id="[% f.name %]" value="[% f.fif %]"></td>
    </tr>
    [% END %]
    <tr><td><input type="submit" name="submit" id="submit" value="Update" /></td></tr>
    </fieldset>
    </table>
    </form>
    </div>

Now restart the server and try editing your profile.

User Administration

Finally, we will create an interface to manage the users of the app.

For this part you will need the modules Crypt::PassGen and Catalyst::View::Email.

Create an email view:

    perl script/myapp_create.pl view Email::Template Email::Template

And add its configuration to myapp.conf:

    <View::Email::Template>
        <sender>
            mailer Sendmail
        </sender>
        template_prefix email
        <default>
            content_type text/html
            charset utf-8
            view HTML
        </default>
    </View::Email::Template>

    default_view HTML

In the User controller, add these use statements at the top:

    use Crypt::PassGen 'passgen';
    use MyApp::Form::AddUser        ();
    use MyApp::Form::EditUser       ();

And these actions:

    sub list : Chained('admin') PathPart('list') Args(0) {
        my ($self, $c) = @_;

        my $users = $c->model('DB::User')->search(
            { active => 'Y'},
            {
                order_by => ['username'],
                page     => ($c->req->param('page') || 1),
                rows     => 20,
            }
        );

        $c->stash(
            users => $users,
            pager => $users->pager,
        );
    }

    sub add : Chained('admin') PathPart('add') Args(0) {
        my ($self, $c) = @_;

        my $form = MyApp::Form::AddUser->new;

        $c->stash(form => $form);

        my $user = $c->model('DB::User')->new_result({});

        my ($temp_password) = passgen(NWORDS => 1, NLETT => 8);

        $user->password($temp_password);
        $user->password_expires(DateTime->now);
        $user->active('Y');

        return unless $form->process(
            schema => $c->model('DB')->schema,
            item   => $user,
            params => $c->req->body_parameters,
        );

        $c->stash->{email} = {
            to           => $user->email_address,
            from         => 'admin@myapp.org',
            subject      => 'Welcome to MyApp',
            content_type => 'text/html',
            template     => 'welcome.tt2',
        };

        $c->stash(
            username => $user->username,
            password => $temp_password,
        );

        $c->forward($c->view('Email::Template'));

        $c->res->redirect($c->uri_for($self->action_for('list'), {
            status_msg => 'User '
                . $user->username
                . ' created successfully'
                . ', initial password emailed ' . 'to '
                . $user->email_address
        }));
    }

    sub user : Chained('admin') PathPart('') CaptureArgs(1) {
        my ($self, $c, $user_id) = @_;

        $c->stash(user => $c->model('DB::User')->find($user_id));
    }

    sub inactivate : Chained('user') PathPart('inactivate') Args(0) {
        my ($self, $c) = @_;

        my $user = $c->stash->{user};

        $user->update({ active => 'N' });

        my $username = $user->username;

        $c->res->redirect($c->uri_for($self->action_for('list'), {
            status_msg => "User $username inactivated"
        }));
    }

    sub reset_password : Chained('user') PathPart('reset_password') Args(0) {
        my ($self, $c) = @_;

        my $user = $c->stash->{user};

        my ($temp_password) = passgen(NWORDS => 1, NLETT => 8);

        $user->password($temp_password);
        $user->password_expires(DateTime->now);
        $user->update;

        $c->stash->{email} = {
            to           => $user->email_address,
            from         => 'admin@myapp.org',
            subject      => 'Your MyApp Password has been Reset',
            content_type => 'text/html',
            template     => 'reset_password.tt2',
        };

        $c->stash(
            username => $user->username,
            password => $temp_password,
        );

        $c->forward($c->view('Email::Template'));

        $c->res->redirect($c->uri_for($self->action_for('list'), {
            status_msg => 'Password reset email for '
                . $user->username
                . ' sent to '
                . $user->email_address
        }));
    }

    sub edit : Chained('user') PathPart('edit') Args(0) {
        my ($self, $c) = @_;

        my $form = MyApp::Form::EditUser->new;

        $c->stash(form => $form);

        return unless $form->process(
            schema  => $c->model('DB')->schema,
            item_id => $c->stash->{user}->id,
            params  => $c->req->body_parameters,
        );

        $c->res->redirect($c->uri_for($self->action_for('list'), {
            status_msg => 'User '
                . $c->stash->{user}->username
                . ' updated successfully'
        }));
    }

NOTE: the from email address must be a valid address, otherwise your MTA will reject the email, replace it with your email address before testing this controller.

There are two email templates, one for the add action which goes into root/src/email/welcome.tt2:

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml"
          xml:lang="en"
          lang="en">
    <head>
    </head>
    <body>
    <h2 align="center">Welcome to the MyApp system.</h2>
    <p>Your username is: <span style="color: green;">[% username %]</span></p>
    <p>Your initial password is: <span style="color: red;">[% password %]</span></p>
    <p>You will be asked to change your password on first login.</p>
    </body>
    </html>

And one for password_reset which goes into root/src/email/reset_password.tt2:

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml"
          xml:lang="en"
          lang="en">
    <head>
    </head>
    <body>
    <h2 align="center">Your MyApp Password has been Reset</h2>
    <p>Your username is: <span style="color: green;">[% username %]</span></p>
    <p>Your password is: <span style="color: red;">[% password %]</span></p>
    <p>You will be asked to change your password on first login.</p>
    </body>
    </html>

Four web templates, root/src/user/list.tt2:

    [% META title = 'MyApp: User Admin' %]

    <br />
    <a class="button" href="[% c.uri_for('/user/add') %]" onclick='this.blur();'><span>Add User</span></a>
    <br />
    Displaying users [% pager.first %]-[% pager.last %] of [% pager.total_entries %]

    <table>
    <tr>
        <th>Username</th>
        <th>Name</th>
        <th>Email Address</th>
    </tr>
    [% WHILE (u = users.next) %]
    <tr>
    <td><a href="[% c.uri_for('/user', u.id, 'edit') %]">[% u.username %]</a></td>
    <td>[% u.name %]</td>
    <td>[% u.email_address %]</td>
    <td><a href="[% c.uri_for('/user', u.id, 'reset_password') %]">Reset Password</a></td>
    <td><a href="[% c.uri_for('/user', u.id, 'inactivate') %]">Inactivate</a></td>
    </tr>
    [% END %]
    </table>

    &lt;&lt; 
    <a href="[% c.req.uri_with({ page => pager.first_page }) %]">First</a>
    <a href="[% c.req.uri_with({ page => pager.previous_page })%]">Previous</a>
    |
    <a href="[% c.req.uri_with({ page => pager.next_page })%]">Next</a>
    <a href="[% c.req.uri_with({ page => pager.last_page }) %]">Last</a>
    &gt;&gt;

root/src/user/add.tt2:

    [% META title = 'MyApp: Add User' %]

    <div>
    <form name="[% form.name %]" action="[% c.req.uri %]" method="post">

    [% FOR field IN form.error_fields %]
        [% FOR error IN field.errors %]
            <p><span style="color: red;">[% field.label _ ': ' _ error %]</span></p>
        [% END %]
    [% END %]

    <fieldset style="border: 0;">
    <table>
    <tr>
    [% f = form.field('username') %]
    <td><label for="[% f.name %]">[% f.label %]:</label></td>
    <td><input type="text" size=30 name="[% f.name %]" id="[% f.name %]" value="[% f.fif %]"></td>
    </tr>
    [% PROCESS user/edit_details.tt2 %]
    <tr>
        <td><input type="submit" name="submit" id="submit" value="Add" /></td>
        <td><a href="/user/list">Users List</a></td>
    </tr>
    </fieldset>
    </table>
    </form>
    </div>

root/src/user/edit.tt2:

    [% META title = 'MyApp: Edit User' %]

    <div>
    <form name="[% form.name %]" action="[% c.req.uri %]" method="post">

    [% FOR field IN form.error_fields %]
        [% FOR error IN field.errors %]
            <p><span style="color: red;">[% field.label _ ': ' _ error %]</span></p>
        [% END %]
    [% END %]

    <fieldset style="border: 0;">
    <table>
    [% PROCESS user/edit_details.tt2 %]
    <tr>
        <td><input type="submit" name="submit" id="submit" value="Update" /></td>
        <td><a href="/user/list">Users List</a></td>
    </tr>
    </fieldset>
    </table>
    </form>
    </div>

and root/src/user/edit_details.tt2:

    [% FOREACH field_name = ['name', 'email_address',
                             'phone_number', 'mail_address'] %]
    <tr>
    [% f = form.field(field_name) %]
    <td><label class="text.label" for="[% f.name %]">[% f.label %]:</label></td>
    <td><input class="text" type="text" size=30 name="[% f.name %]" id="[% f.name %]" value="[% f.fif %]"></td>
    </tr>
    [% END %]
    <tr>
    [% f = form.field('roles') %]
    <td><label for="[% f.name %]">Roles:</label></td>
    <td>[% f.render %]</td>
    </tr>

And the two form classes, lib/MyApp/Form/AddUser.pm:

    package MyApp::Form::AddUser;

    use HTML::FormHandler::Moose;
    extends 'MyApp::Form::EditUser';
    use namespace::autoclean;

    has_field 'username' => (
        type     => 'Text',
        label    => 'User name',
        required => 1,
    );

    __PACKAGE__->meta->make_immutable;

    1;

and lib/MyApp/Form/EditUser.pm:

    package MyApp::Form::EditUser;

    use HTML::FormHandler::Moose;
    extends 'MyApp::Form::UserProfile';
    use namespace::autoclean;

    has_field 'roles' => (
        type         => 'Multiple',
        widget       => 'checkbox_group',
        label_column => 'name',
        label        => '',
    );

    __PACKAGE__->meta->make_immutable;

    1;

Now try it out, launch the server and click the Admin link.

I've taken you on a tour of a simple intranet auth system using modern Catalyst technologies, hopefully you got some ideas about how to implement your own auth and administration systems.

AUTHOR

Caelum: Rafael Kitover <rkitover@cpan.org>