The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.

NAME

Class::User::DBI - A User class: Login credentials and roles.

VERSION

Version 0.01_001

SYNOPSIS

Through a DBIx::Connector object, this module models a "User" class, with login credentials, and access roles. Login credentials include a passphrase, and optionally per user IP whitelisting.

    # Set up a connection using DBIx::Connector:
    # MySQL database settings:

    my $conn = DBIx::Connector->new(
        'dbi:mysql:database=cudbi_tests, 'testing_user', 'testers_pass',
        {
            RaiseError => 1,
            AutoCommit => 1,
        }
    );


    # Now we can play with Class::User::DBI:

    Class::User::DBI->configure_db( $conn );  # Set up the tables for a user DB.

    my @user_list = Class::User::DBI->list_users;

    my $user = new( $conn, $userid );

    my $user_id  = $user->add_user(
        {
            password => $password,
            ip_req   => $bool_ip_req,
            ips      => [ '192.168.0.100', '201.202.100.5' ], # aref ip's.
            username => $full_name,
            email    => $email,
        }
    );

    my $userid      = $user->userid;        # Just returns the object's userid.

    my $validated   = $user->validated;     # The user has been authenticated.

    my $invalidated = $user->validated(0);  # Remove authentication.

    my $is_valid    = $user->validate( $pass, $opt_ips );   # Authenticate the user.

    my $is_valid    = $user->validate( $pass ); # Authentiate without IP.

    my $info_href   = $user->load_profile;   # Load the user's profile.

    my @valid_ips   = $user->fetch_valid_ips;

    my $user_exists = $user->exists_user;

    my $success     = $user->delete_user;

    my $del_count   = $user->delete_ips( @ips );

    my $add_count   = $user->add_ips( @ips );

    my $success     = $user->update_email( 'new@email.address' );

    my $success     = $user->update_username( 'Cool New User Name' );

    my $success     = $user->update_password( 'Old Pass', 'New Pass' );

    my $success     = $user->update_password( 'New Pass' );

    my $can_do      = $user->can_role( $role );

    my $add_count   = $user->add_roles( @roles );

    my $del_count   = $user->delete_roles( @roles );

    my @roles       = $user->fetch_roles;

DESCRIPTION

The module is designed to simplify user logins, authentication, authorization, and basic administrative user maintenance. It stores user credentials, roles, and basic user information in a database via a DBIx::Connector database connection.

User passphrases are salted with a 512 bit random salt (unique per user) using a cryptographically strong random number generator, and converted to a SHA2-512 digest before being stored in the database. All subsequent passphrase validation checks test against the salt and passphrase SHA2 hash.

IP whitelists may be maintained per user. If a user is set to require an IP check, then the user validates only if his passphrase authenticates AND his IP is found in the whitelist associated with his user id.

Users may be given zero or more roles. Roles are simple strings, and may be used by an authorization framework to determine what aspects of an application's functionality will be available to a given user, or how the functionality is presented.

To use, instantiate a user object. This user's initial state holds only a userid. Validate, load user info, load roles, test roles... read on.

EXPORT

Nothing is exported. There are many object methods, and three class methods, described in the next section.

SUBROUTINES/METHODS

new (The constructor -- Class method.)

    my $user_obj = Class::User::DBI->new( $connector, $userid );

Instantiates a new Class::User::DBI object in behalf of a target user on a database handled by the DBIx::Connector.

The user object may be accessed and manipulated through the methods listed below.

fetch_credentials

    my $credentials_href = $user->fetch_credentials;
    my @fields = qw( userid salt_hex pass_hex ip_required );
    foreach my $field ( @fields ) {
        print "$field => $credentials_href->{$field}\n";
    }
    my @valid_ips = @{$valid_ips};
    foreach my $ip ( @valid_ips ) {
        print "Whitelisted IP: $ip\n";
    }

Accepts no parameters. Returns a hashref holding a small datastructure that describes the user's credentials. The structure looks like this:

    $href = {
        userid      => $userid,     # The target user's userid.

        salt_hex    => $salt,       # A 128 hex-character representation of
                                    # the user's random salt.

        pass_hex    => $pass,       # A 128 hex-character representation of
                                    # the user's SHA2-512 digested passphrase.

        ip_required => $ip_req,     # A Boolean value indicating whether this
                                    # user requires IP whitelist validation.

        valid_ips   => [            # Whitelisted IP's for user. (optional)
            '127.0.0.1',                # Some example whitelisted IP's.
            '129.168.0.10',
        ],
    };

A typical usage probably won't require calling this function directly very often, if at all. In most cases where it would be useful to look at the salt, the passphrase digest, and IP whitelists, the $user->validate( $passphrase, $ip ) method is easier to use and less prone to error. But for those cases I haven't considered, the fetch_credentials() method exists.

load_profile

    my $user_info_href = $user->load_profile;
    foreach my $field ( qw/ userid username email / ) {
        print "$field   => $user_info_href->{$field}\n";
    }

Returns a reference to an anonymous hash containing the user's basic profile information. Currently the datastructure looks like this:

    my $user_info_href = {
        userid      => $userid,     # The primary user ID.
        username    => $username,   # The full user name as stored in the DB.
        email       => $email,      # The email stored in the DB for this user.
    };

Although additional fields could be added to the database table and this module could be subclassed to process those fields, it's probably easier to just add another table keyed off of the unique userid field, containing any additional information a given application requires for a user.

delete_user

    $user->delete_user;

Removes the user from the database, along with the user's IP whitelist, and roles. Also sets the $user->validated, and $user->exists_user flags to false.

exists_user

Checks the database to verify that the user exists. As this method is used internally frequently its positive result is cached to minimize database queries. Methods that would invalidate the existence of the user in the database, such as $user->delete_user will remove the cache entry, and subsequent tests will access the database on each call to exists_user(), until such time that the result flips to positive again.

add_user

    my $success = $self->add_user( {
        username    => $user_full_name,     # Optional field. Default q{}.

        password    => $user_passphrase,    # Clear text password.
                                            # Required field. No length limit.

        email       => $user_email,         # Optional field.  Default q{}.

        ip_req      => $ip_validation_reqd  # Boolean value determining whether
                                            # this user requires IP whitelist
                                            # validation. ( 0 = no, 1 = yes ).
                                            # Optional field.  Default 0.

        ips_aref    => [                    # Optional field.  Default is empty
            '127.0.0.1',                    # list.  If 'ip_req' is set and no
            '192.168.0.1',                  # list is provided here, then valid
        ],                                  # IP's will need to be added later
                                            # before user can validate.
    } );

This method creates a new user in the database with the userid supplied when the user object was instantiated. The password field is the only required field. It must contain a clear-text passphrase. There is no length limitation.

Other fields are optional, but convenient. If IP whitelisting is needed for this user, the ip_req field must be supplied, and must be set to 1 (true).

If ip_req is set to 1 (true), a list of valid IP's may also be provided in an arrayref keyed off of ips_aref. As a convenience, the ips key is synonymous with ips_aref. The IP's provided will then be added to the user_ips database table. If an IP is required but none are added via add_user, they will have to be added manually with add_ips before the user can be validated.

The user's passphrase will be salted with a cryptographically sound random salt of 512 bits (128 hex digits). It will then be digested using a SHA2-512 hash, and both the salt and the digest will be stored in the users database.

This is a reliable and secure means of storing a passphrase. In fact, the passphrase is not stored at all. Just a salt and the digest. Even if the salt and hash were to be discovered by an attacker, they would not be useful in side-stepping user validation, as they cannot be used to decrypt the passphrase. SHA512 is the strongest of the SHA2 family. A salt length of 512 bits guarantees a maximum entropy for any given passphrase.

Though it is beyond the scope of this module to do so, users should be encouraged to use passphrases that are both resistant to dictionary attacks, and dissimilar to passphrases used in other applications. No minimum passphrase size is enforced by this module. But a strong passphrase should be of ample length, and should contain characters beyond the standard alphabet.

update_email

    my $success = $user->update_email( $new_email_address )

Email addresses are not verified for validity in any way. However, the default database field used for storing email addresses provides 320 bytes of storage, which is the maximum length possible for a valid email address.

update_password

    # Update with validation of old password first.
    my $success = $user->update_password( $new_pass, $old_pass );

    # Update without validation of old password first.
    my $success = $user->update_password( $new_pass );

Using the same algorithms of add_user( { password = $passphrase } ); >>, creates a new password for the user. If the old passphrase is supplied as a second parameter, the update will only take place if the old passphrase validates.

The "with validation" method is useful for allowing a user to update her own password. The "without validation" version is useful for allowing an administrator (or automated process) to reset a user's forgotten password.

update_username

    my $success = $user->update_username( $new_user_full_name );

There's probably not much need for explaining this method. The default database table's username field accepts user names up to fourty characters.

userid

    my $userid = $user->userid;

A simple accessor returning the userid that is the target of the Class::User::DBI object.

validate

    # If no IP whitelist verification is required:
    my $is_valid  = $user->validate( $passphrase );

    # If IP whitelist verification is required:
    my $is_valid = $user->validate( $passphrase, $current_ip );

Returns true if and only if the user can be validated. What that means will be described in the paragraphs below. If the user cannot be validated, the return value will be undef. It doesn't matter what the reason for failure to authenticate might have been: Invalid user ID, invalid password, or invalid IP address; all three reasons result in a return value of undef. This behavior encourages the best practice of not divulging to the user why his authentication failed. The less information provided, the less an attacker can user to narrow the field.

What Validation (or Authentication) Means To This Module

If the user has been configured for no IP testing, validation means that the userid exists (case insensitively) within the database, and that the passphrase passed to validate(), when salted with the stored salt and digested using a SHA2-512 hashing algorithm results in the same 512 bit hash as the one generated when the passphrase was originally set up.

If the user has been configured to require IP testing, validation also means that the IP supplied to the validate() method matches one of the IP's stored in the database for this user. IP's are stored in the clear, which shouldn't matter. User input should never be used for the IP field of validate(). It is assumed that within the application, the user's IP will be detected, and that IP will be passed for cross-checking with the whitelist database.

The validate() method caches its positive result. Any action that might change the authentication status will remove the cached status. Actions that will result in validate() to perform all tests again include delete_user(), update_password(), or validated(0) (passing the validated() method a '0'.

validated

    # Test.
    my $has_been_validated = $user->validated;

    # Invalidate.
    $user->validated(0);

Returns true if the user has been validated, as described above. Does not perform a full validation; simply tests whether the previous call to validate() succeeded, and that nothing has happened to remove that "is valid" status.

Pass a parameter of '0' to force all future calls to validated() to return false. Also, after resetting validated() to false, future calls to validate() will go through the full authentication process again until such time as the authentication is successful.

fetch_valid_ips

    my @valid_ips = $user->fetch_valid_ips;

Returns a list containing the list of whitelisted IP's for this user. Each IP will be a string in the form of 192.168.0.198. If the user doesn't use IP validation, or there are no IP's stored for this user, the list will be empty.

add_ips

    my $quantity_added = $user->add_ips ( @whitelisted_ips );

Pass a list of IP's to add to the IP whitelist for this user. Any IP's that are already in the database will be silently skipped.

Returns a count of how many were added.

delete_ips

    my $quantity_deleted = $user->delete_ips( @ips_to_remove );

Pass a list of IP's to remove from the IP whitelist for this user. Any IP's that weren't found in the database will be silently skipped.

Returns a count of how many IP's were dropped.

fetch_roles

    my @roles = $user->fetch_roles;

Returns a list of roles this user has. Roles are simply strings that may be used to identify a resource a user is authorized to access.

can_role

    my $can_access  = $user->can_role( $role_name );

Returns a true value if the user has the named role. False otherwise.

add_roles

    my $quantity_added = $user->add_roles( @list_of_roles );

Pass a list of roles to add for the target user. Roles already duplicated in the database will be silently skipped. Returns a count of how many roles were added.

delete_roles

    my $quantity_deleted = $user->delete_roles( @list_of_roles );

Pass a list of roles to delete for the target user. Roles not found in the database will be silently skipped. Returns a count of how many roles were actually deleted.

list_users (Class method)

    my @users = Class::User::DBI->list_users( $connector );
    foreach my $listed_user ( @users ) {
        my( $userid, $username, $email ) = @{$listed_user};
        print "userid: ($userid).  username: ($username).  email: ($email).\n";
    }

This is a class method. Pass a valid DBIx::Connector as a parameter. Returns a list of arrayrefs. Each anonymous array contains userid, username, and email.

configure_db (Class method)

    Class::User::DBI->configure_db( $connector );

This is a class method. Pass a valid DBIx::Connector as a parameter. Builds a minimal set of database tables in support of the Class::User::DBI.

The tables created will be users, user_ips, and user_roles.

DEPENDENCIES

This module requires DBIx::Connector, Authen::Passphrase::SaltedSHA512, and List::MoreUtils. It also requires a database connection. The test suite will use DBD::SQLite, but it has also been tested with DBD::mysql. None of these dependencies with the exception of List::MoreUtils could be considered light-weight. The dependency chain of this module is indicative of the difficulty in assuring cryptographically strong random salt generation, reliable SHA2-512 hashing of passphrases, fork-safe database connectivity, and transactional commits for inserts and updates spanning multiple tables.

CONFIGURATION AND ENVIRONMENT

The database used will need at least three User-related tables. In their simplest form, a minimal recommendation would be:

    TABLE:          users
    COLUMNS:        userid      VARCHAR(24)  NOT NULL DEFAULT ''
                    salt        CHAR(128)    NOT NULL DEFAULT ''
                    password    CHAR(128)    NOT NULL DEFAULT ''
                    ip_required tinyint(1)   NOT NULL DEFAULT '1'
                    username    VARCHAR(40)  DEFAULT NULL
                    email       VARCHAR(320) DEFAULT NULL
    PRIMARY KEY:    userid


    TABLE:          user_ips
    COLUMNS:        userid      VARCHAR(24)  NOT NULL DEFAULT ''
                    ip          INT(10) UNSIGNED NOT NULL DEFAULT '0'
    PRIMARY KEY:    userid, ip


    TABLE:          user_roles
    COLUMNS:        userid      VARCHAR(24) NOT NULL DEFAULT ''
                    role        VARCHAR(40) NOT NULL DEFAULT ''
    PRIMARY KEY:    userid, role

For convenience, a class method has been provided that will auto-generate the minimal schema within a SQLite or MySQL database. The SQLite database is probably only useful for testing, as it lacks many of the security measures present in web-stack quality databases. A sample script that would generate a minimal database could be as simple as this:

    use strict;
    use warnings;
    use DBIx::Connector;
    use Class::User::DBI;

    my( $dsn, $dbuser, $dbpass )
        = (
            'dbi:mysql:database=cudbi_tests,
            'testing_user',
            'testers_pass'
        );

    my $conn = DBIx::Connector->new(
        $dsn,
        $dbuser,
        $dbpass,
        {
            AutoCommit  => 1,
            RaiseError  => 1,
        }

    );

    Class::User::DBI->configure_db( $conn )

    __END__

Within the scripts/ directory of this distribution you will find a script that accepts a database type (mysql or sqlite), database name, database username, and database password on the command line. It then opens the given database and creates the appropriate tables using a variation on the snippet shown above. The script is named cudbi-configdb. Run it once without any command line parameters to see details on usage.

After creating the database framework, it might be useful to alter the tables that have been generated by customizing field widths, text encoding, and so on. It may be advisable to enable UTF8 for the userid, email, username fields, and possibly even for the role field.

There is no explicit size requirement for the userid, username, and role fields. They could be made wider if it's deemed useful. Don't be tempted to reduce the size of the email address field: The best practice of coding to the standard dictates that the field needs to be 320 characters wide.

The salt and password fields are used to store a 128 hex-digit representation of the 512 bit salt and 512 bit SHA2 hash of the user's passphrase. More digits is not useful, and less won't store the full salt and hash.

DIAGNOSTICS

If you find that your particular database engine is not playing nicely with the SQL from this module, it could be helpful to modify the test script found in t/15-class_tests.t, within the distribution's directory by providing the database login credentials for a test database using the same engine that your application will actually be using.

Currently the test suite tests against a SQLite database since it's such a lightweight dependency for the testing. The author also uses this module with several MySQL databases. As you're configuring your database, providing its credentials to t/15-class_tests.t and running the test script will offer really good diagnostics if some aspect of your database tables proves to be at odds with what this module needs.

Be advised that the the test suite drops its tables after completion, so be sure to run t/15-class_tests.t only on a database set up explicitly for testing purposes.

INCOMPATIBILITIES

This module has only been tested on MySQL and SQLite database engines. If you are successful in using it with other engines, please send me an email detailing any additional configuration changes you had to make so that I can document the compatibility, and improve the documentation for the configuration process.

BUGS AND LIMITATIONS

This module is still in beta testing. The API of any version number in the form of 'xxx.yyy_zzz' could still change. Once the version reaches the form of 'xxx.yyy', the API may be considered stable.

AUTHOR

David Oswald, <davido at cpan.org>

BUGS

Please report any bugs or feature requests to bug-class-user-dbi at rt.cpan.org, or through the web interface at http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Class-User-DBI. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.

SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc Class::User::DBI

You can also look for information at:

ACKNOWLEDGEMENTS

LICENSE AND COPYRIGHT

Copyright 2012 David Oswald.

This program is free software; you can redistribute it and/or modify it under the terms of either: the GNU General Public License as published by the Free Software Foundation; or the Artistic License.

See http://dev.perl.org/licenses/ for more information.