Beefy Boxes and Bandwidth Generously Provided by pair Networks
laziness, impatience, and hubris
 
PerlMonks  

RFC / Audit: Mojo Login Example

by haukex (Chancellor)
on Mar 22, 2020 at 09:51 UTC ( #11114542=perlquestion: print w/replies, xml ) Need Help??

haukex has asked for the wisdom of the Perl Monks concerning the following question:

Hi everyone,

Over at Re: 302 Found Location Message I posted an example of some login code with Mojolicious. I've now expanded that code to add a brute force attack deterrent. I would greatly appreciate a set of critical eyes - are there any gaps in the protection, and is there something else I'm missing to improve the security even more? (I've thought about hashing the password on the client side, but I'd prefer not to make it dependent on JavaScript - HTTPS will have to do for now.) For example, I could add a delay for each remote IP with failed login attempts, or I could grow the delay time exponentially.

#!/usr/bin/env perl use 5.028; use Mojolicious::Lite -signatures; use Mojo::SQLite; use File::Spec::Functions qw/catfile tmpdir/; use PBKDF2::Tiny qw/derive_hex verify_hex/; use Crypt::Random::Source qw/get_strong/; # Run me via: # morbo --listen=http://127.0.0.1:3000 --listen=https://127.0.0.1:4430 + mojo_login_example.pl #app->secrets(['A Login Example - TODO: set this string!']); app->sessions->secure(1); # disable template cache in development mode (e.g. under morbo): app->renderer->cache->max_keys(0) if app->mode eq 'development'; helper sql => sub { state $sql = Mojo::SQLite->new('sqlite:' . catfile(tmpdir, 'test.db') ) }; app->sql->migrations->from_string(<<'END_MIGRATIONS')->migrate; -- 1 up CREATE TABLE Users ( Username TEXT NOT NULL PRIMARY KEY, Salt TEXT NOT NULL, Password TEXT NOT NULL, AuthAttempts INTEGER NOT NULL DEFAULT 0, DelayExpires INTEGER ); -- 1 down DROP TABLE IF EXISTS Users; END_MIGRATIONS { my $db = app->sql->db; # for testing, insert sample users: if ( not $db->query('SELECT COUNT(*) FROM Users')->arrays->[0][0] ) { my $salt1 = unpack 'H*', get_strong(64); $db->insert('Users', { Username => 'Foo', Salt => $salt1, Password => derive_hex('SHA-512', 'Bar', $salt1, 5000) } ); my $salt2 = unpack 'H*', get_strong(64); $db->insert('Users', { Username => 'Quz', Salt => $salt2, Password => derive_hex('SHA-512', 'Baz', $salt2, 5000) } ); } } helper do_login => sub ($c) { my $promise = Mojo::Promise->new; my ($user,$pass) = ($c->param('username'), $c->param('password')); my $db = $c->sql->db; my $user_rec = eval { my $tx = $db->begin('exclusive'); my $u = $db->select( Users => [qw/ Salt Password AuthAttempts DelayExpires /], { Username => $user } )->hashes; die "Username not found" unless @$u; $db->query('UPDATE Users SET AuthAttempts=AuthAttempts+1,' .'DelayExpires=? WHERE Username=?', time+60*60, $user ); $tx->commit; $u->[0] } or do { Mojo::IOLoop->timer( 2 => sub { $promise->reject } ); return $promise }; Mojo::IOLoop->timer( 2 * $user_rec->{AuthAttempts} => sub { utf8::encode( $pass ); # needed for verify_hex utf8::encode( my $salt = $user_rec->{Salt} ); if ( verify_hex( $user_rec->{Password}, 'SHA-512', $pass, $salt, 5000 ) ) { $db->query('UPDATE Users SET AuthAttempts=0,DelayExpires=' .'NULL WHERE Username=? OR ?>DelayExpires', $user, time ); $promise->resolve($user); } else { Mojo::IOLoop->timer( 2 => sub { $promise->reject } ) } }); return $promise; }; helper logged_in => sub ($c) { length( $c->session('username') ) ? $c->session : undef }; any '/' => sub ($c) { $c->render('index') } => 'index'; group { # everything in this group requires HTTPS b/c of this "under": under sub ($c) { return 1 if $c->req->is_secure; $c->redirect_to( $c->url_for->to_abs->scheme('https') ->port(4430) ); # this port is just for this demo return undef; }; get '/login' => sub ($c) { $c->render('login') } => 'login'; post '/login' => sub ($c) { # form handler return $c->render(text => 'Bad CSRF token!', status => 403) if $c->validation->csrf_protect->has_error('csrf_token'); $c->render_later; $c->do_login->then(sub ($user) { $c->session( expiration => 60*60 ); $c->session( username => $user ); $c->redirect_to('secure'); })->catch(sub { $c->flash(login_error => 'Bad username or password'); $c->redirect_to('login'); }); } => 'login'; any '/logout' => sub ($c) { delete $c->session->{username}; $c->redirect_to('index'); } => 'logout'; group { # everything in this group requires login under sub ($c) { return 1 if $c->logged_in; $c->redirect_to('login'); return undef; }; any '/secure' => sub ($c) { $c->render('secure') } =>'secure'; }; }; app->start; __DATA__ @@ layouts/main.html.ep <!DOCTYPE html> <html> <head><title><%= title %></title> <style> table { border-collapse: collapse; } table, th, td { border: 1px solid black; } </style> </head><body> <nav style="margin-bottom:1em;"><small> [ <%= link_to Main => 'index' %> | <%= link_to Secure => 'secure' %> ] % if ( my $s = logged_in ) { [ Logged in as <%= $s->{username} %> | <%= link_to Logout => 'logo +ut' %> ] % } else { [ <%= link_to Login => 'login' %> ] % } </small></nav> <main> <div style="margin-bottom:1em;"><table style="font-size:0.75em;"> <tr> <th colspan="3">Debug Info</th> </tr> <tr> <th>User</th> <th>Attemps</th> <th>Expires</th> </tr> % my $res = sql->db->select('Users', [qw/ Username AuthAttempts DelayE +xpires /]); % while ( my $row = $res->array ) { <tr> <% for my $f (@$row) { %> <td><%= $f %></td> <% } %> </tr> <% } %></table></div> <%= content %> </main> </body> </html> @@ index.html.ep % layout 'main', title => 'Hello, World!'; <div>Hello, World!</div> @@ login.html.ep % layout 'main', title => 'Login'; % if ( flash 'login_error' ) { <div style="margin-bottom:1em;"><strong><%= flash 'login_error' %> +</strong></div> % } <div> %= form_for login => ( method => 'post' ) => begin %= csrf_field %= label_for username => 'Username' %= text_field username => ( placeholder=>"Username", required=>'requir +ed' ) %= label_for password => 'Password' %= password_field password => ( placeholder=>"Password", required=>'re +quired' ) %= submit_button 'Login' %= end </div> @@ secure.html.ep % layout 'main', title => 'Top Secret'; <div>Welcome <b><%= session->{username} %></b>, you've accessed the <b>top secret</b> area!</div>

Replies are listed 'Best First'.
Re: RFC / Audit: Mojo Login Example
by haj (Chaplain) on Mar 22, 2020 at 13:50 UTC

    Disclaimer: I'm not at all familiar with Mojo, so there's a bit of guesswork while I'm interpreting the code.

    • Hashing at the client side is no good idea anyway. You don't want to expose the salt to the client - but without salt hashing doesn't give better security.
    • These days I often see attacks where a list of user ids is probed, once for each user id. This seems to be an attack pattern especially in forum or wiki software where a set of valid login names is easily available for unauthenticated readers.
    • An exponential rise is double-edged: If you apply it per session, then attackers aren't affected if they create a new session for every attempt. Applying it per login allows another attack: Lock out users by repeatedly attempting an invalid login. Robots are patient, but users aren't.

    So what should be included is:

    • Inform users about the number of unsuccessful login attempts since his last successful login. For low numbers it will keep them informed about the value of choosing a good password, for high numbers they might want to inform the server guys.
    • Logging both failed and successful logins can help admins to detect ongoing attacks. The heuristics to detect attack patterns from logging data is outside the scope of the application, but it should at least be made possible. A decent but constant delay between logins gives admins sufficient time to think about appropriate countermeasures.

      Thank you for the thoughtful reply!

      You don't want to expose the salt to the client - but without salt hashing doesn't give better security.

      Yes, I should have been more clear on this - I would take a hash of only the password on the client side, say SHA-512 multiple times, and additionally do the same hashing+salt on the server. That way, the cleartext password is never seen by the server, and provided the hash isn't in a rainbow table somewhere, it adds a tiny bit more security.

      All of your points are excellent, and yes, I see that perhaps a per-IP delay or lockout on too many attempts might even be better than the current implementation (in Mojo: $c->tx->remote_address). Even though I agree logging is very important, did leave it out of this example... but luckily Mojo makes it fairly easy to add: app->log->warn("..."), app->log->error("...") and so on, and it can be redirected into a database as well via the event mechanism built into Mojo::Log.

Re: RFC / Audit: Mojo Login Example
by Your Mother (Bishop) on Mar 23, 2020 at 21:00 UTC

    I have not played with the code though I should. I wonder why PBKDF2 instead of Bcrypt. Even with all the time passed making the latter the ageing technology… it’s never been broken—which is a better and better sign with an older algorithm—and with standard hardware it’s still harder to brute force.

    This kind of thing is deviously difficult to do simply, correctly, and cleanly so I really appreciate you putting an implementation forward.

      I wonder why PBKDF2 instead of Bcrypt.

      Just a recommendation I found while researching, but yes, there are quite a few alternatives (for example, some databases have this kind of functionality built in, e.g. pgcrypto). My main intent was to show that this kind of thing is necessary in general.

      This kind of thing is deviously difficult to do simply, correctly, and cleanly

      Yes, I'm definitely feeling that - so many different guides and recommendations that it's hard to keep track of what's current, reasonable, etc.

Re: RFC / Audit: Mojo Login Example
by Anonymous Monk on Mar 23, 2020 at 08:43 UTC
A reply falls below the community's threshold of quality. You may see it by logging in.

Log In?
Username:
Password:

What's my password?
Create A New User
Node Status?
node history
Node Type: perlquestion [id://11114542]
Front-paged by Corion
help
Chatterbox?
and the web crawler heard nothing...

How do I use this? | Other CB clients
Other Users?
Others having an uproarious good time at the Monastery: (7)
As of 2020-04-04 12:45 GMT
Sections?
Information?
Find Nodes?
Leftovers?
    Voting Booth?
    The most amusing oxymoron is:
















    Results (32 votes). Check out past polls.

    Notices?