Sfoglia il codice sorgente

WIP: password and TOTP reset facilities.

George Baugh 2 anni fa
parent
commit
05ef78c6c3

+ 21 - 0
bin/migrate5.pl

@@ -0,0 +1,21 @@
+#!/usr/bin/env perl
+
+# Password reset code
+
+use strict;
+use warnings;
+
+use FindBin;
+
+use lib "$FindBin::Bin/../lib";
+
+use Trog::SQLite;
+sub _dbh {
+    my $file   = 'schema/auth.schema';
+    my $dbname = "config/auth.db";
+    return Trog::SQLite::dbh($file,$dbname);
+}
+
+my $dbh = _dbh();
+
+$dbh->do("ALTER TABLE user ADD COLUMN contact_email TEXT DEFAULT NULL;");

+ 1 - 0
bin/tcms-useradd

@@ -13,4 +13,5 @@ my ($user, $pass) = @ARGV;
 die "User must be first arg" unless $user;
 die "Password must be second arg" unless $pass;
 
+Trog::Auth::killsession($user);
 Trog::Auth::useradd($user, $pass, ['admin']);

+ 1 - 2
lib/TCMS.pm

@@ -21,7 +21,6 @@ use IO::Compress::Gzip();
 use Time::HiRes      qw{gettimeofday tv_interval};
 use HTTP::Parser::XS qw{HEADERS_AS_HASHREF};
 use List::Util;
-use UUID::Tiny();
 use URI();
 
 #Grab our custom routes
@@ -73,7 +72,7 @@ sub app {
 
     return _toolong() if length( $env->{REQUEST_URI} ) > 2048;
 
-    my $requestid = eval { UUID::Tiny::create_uuid_as_string( UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS ) } // "00000000-0000-0000-0000-000000000000";
+    my $requestid = Trog::Utils::uuid();
     Trog::Log::uuid($requestid);
 
     # Check eTags.  If we don't know about it, just assume it's good and lazily fill the cache

+ 80 - 5
lib/Trog/Auth.pm

@@ -40,6 +40,50 @@ sub session2user ($sessid) {
     return $rows->[0]->{name};
 }
 
+=head2 user_has_session
+
+Return whether the user has an active session.
+If the user has an active session, things like password reset requests should fail when not coming from said session.
+
+=cut
+
+sub user_has_session ($user) {
+    my $dbh  = _dbh();
+    my $rows = $dbh->selectall_arrayref( "SELECT session FROM sess_user WHERE user=?", { Slice => {} }, $user );
+    return 0 unless ref $rows eq 'ARRAY' && @$rows;
+    return 1;
+}
+
+=head2 user_exists
+
+Return whether the user exists at all.
+
+=cut
+
+sub user_exists ($user) {
+    my $dbh  = _dbh();
+    my $rows = $dbh->selectall_arrayref( "SELECT name FROM user WHERE name=?", { Slice => {} }, $user );
+    return 0 unless ref $rows eq 'ARRAY' && @$rows;
+    return 1;
+}
+
+=head2 killsession
+
+Whack the active session for a user.
+Useful for password resets and so forth.
+
+=cut
+
+sub killsession ($user) {
+    my $dbh  = _dbh();
+    my $rows = $dbh->do( "DELETE FROM sess_user WHERE name=?", undef, $user );
+    if ($dbh->errstr()) {
+        WARN("Could not killsession: ".$dbh->errstr());
+        return 0;
+    }
+    return 1;
+}
+
 =head2 acls4user(STRING username) = ARRAYREF
 
 Return the list of ACLs belonging to the user.
@@ -162,15 +206,14 @@ sub expected_totp_code {
 
 =head2 clear_totp
 
-Clear the totp codes for all users
+Clear the totp codes for provided user
 
 =cut
 
-sub clear_totp {
+sub clear_totp($user) {
     my $dbh = _dbh();
-    $dbh->do("UPDATE user SET totp_secret=null") or die "Could not clear user TOTP secrets";
-
-    #TODO notify users this has happened
+    my $res = $dbh->do("UPDATE user SET totp_secret=null WHERE name=?", undef, $user) or die "Could not clear user TOTP secrets";
+    return !!$res;
 }
 
 =head2 mksession(user, pass, token) = STRING
@@ -249,6 +292,38 @@ sub useradd ( $user, $pass, $acls ) {
     return 1;
 }
 
+sub add_change_request ( %args ) {
+    my $res  = $dbh->do( "INSERT INTO change_request (username,token,type,secret) VALUES (?,?,?,?)", undef, $args{user}, $args{token}, $args{type}, $args{secret} );
+    return !!$res;
+}
+
+sub process_change_request ( $token ) {
+    my $dbh  = _dbh();
+    my $rows = $dbh->selectall_arrayref( "SELECT username, type FROM change_request WHERE token=?", { Slice => {} }, $token );
+    return 0 unless ref $rows eq 'ARRAY' && @$rows;
+
+    my $type = $rows->[0]{type};
+    my $user = $rows->[0]{username};
+    my $secret = $rows->[0]{secret};
+    state %dispatch = (
+        reset_pass => sub {
+            my ($user, $pass) = @_;
+            useradd( $user, $pass ) or do {
+               return ''; 
+            };
+            return "Password set to $pass for $user";
+        },
+        clear_totp => sub {
+            my ($user) = @_;
+            clear_totp($user) or do {
+                return '';
+            };
+            return "TOTP auth turned off for $user";
+        },
+    );
+    return $dispatch->{$type}->($user, $secret);
+}
+
 # Ensure the db schema is OK, and give us a handle
 sub _dbh {
     my $file   = 'schema/auth.schema';

+ 8 - 1
lib/Trog/Renderer/json.pm

@@ -17,8 +17,15 @@ Render JSON.  Rather than be templated, we just run the input thru the encoder.
 sub render (%options) {
     my $code    = delete $options{code};
     my $headers = delete $options{headers};
+
+    my %h = (
+        'Content-type' => "application/json",
+        %$headers,
+    );
+
+    delete $options{contenttype};
     my $body    = encode_json(\%options);
-    return [$code, [$headers], [$body]];
+    return [$code, [%h], [$body]];
 }
 
 1;

+ 78 - 1
lib/Trog/Routes/HTML.pm

@@ -48,6 +48,7 @@ our $categorybar  = 'categories.tx';
 our %routes = (
     default => {
         callback => \&Trog::Routes::HTML::setup,
+        noindex  => 1,
     },
     '/index' => {
         method   => 'GET',
@@ -70,17 +71,23 @@ our %routes = (
     #        method   => 'GET',
     #        callback => \&Trog::Routes::HTML::setup,
     #    },
+
+    # IMPORTANT: YOU MUST setup fail2ban rules for the following routes.
+    # TODO: Put a rule in fail2ban/ subdir, make say a generator for it based on the routes having fail2ban=1
     '/login' => {
         method   => 'GET',
         callback => \&Trog::Routes::HTML::login,
+        noindex  => 1,
     },
     '/logout' => {
         method   => 'GET',
         callback => \&Trog::Routes::HTML::logout,
+        noindex  => 1,
     },
     '/auth' => {
         method   => 'POST',
         callback => \&Trog::Routes::HTML::login,
+        noindex  => 1,
     },
     '/totp' => {
         method   => 'GET',
@@ -123,6 +130,22 @@ our %routes = (
         captures => ['module'],
         callback => \&Trog::Routes::HTML::manual,
     },
+    '/request_password_reset' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::resetpass,
+        noindex  => 1,
+    },
+    '/request_password_reset' => {
+        method => 'POST',
+        callback => \&Trog::Routes::HTML::do_resetpass,
+        noindex  => 1,
+    },
+    '/request_totp_clear' => {
+        method => 'POST',
+        callback => \&Trog::Routes::HTML::do_totp_clear,
+        noindex  => 1,
+    },
+    # END FAIL2BAN ROUTES
 
     #TODO transform into posts?
     '/sitemap',
@@ -406,6 +429,7 @@ Return an appropriate robots.txt
 
 =cut
 
+#TODO make this dynamic based on routes with the noindex=1 flag (they'll never see anything behind /auth)
 sub robots ($query) {
     state $etag = "robots-" . time();
     return Trog::Renderer->render(
@@ -660,6 +684,59 @@ sub config ($query) {
     );
 }
 
+=head2 resetpass
+
+=head2 do_resetpass
+
+=head2 do_totp_clear
+
+Routes for user service of their authentication details.
+
+=cut
+
+sub resetpass($query) {
+    $query->{failure} //= -1;
+
+    return Trog::Routes::HTML::index(
+        {
+            title              => 'Request Authentication Resets',
+            theme_dir          => $Trog::Themes::td,
+            stylesheets        => [qw{config.css}],
+            scripts            => [qw{post.js}],
+            message            => $query->{message},
+            failure            => $query->{failure},
+            scheme             => $query->{scheme},
+            template           => 'resetpass.tx',
+            %$query,
+        },
+        undef,
+        [qw{config.css}],
+    );
+}
+
+sub do_resetpass($query) {
+    my $user = $query->{username};
+    # User Does not exist
+    return Trog::Routes::HTML::forbidden($query) if !Trog::Auth::user_exists($user);
+    # User exists, but is not logged in this session
+    return Trog::Routes::HTML::forbidden($query) if !$query->{user} && Trog::Auth::user_has_session($user);
+
+    my $token = Trog::Util::uuid();
+    my $newpass = $query->{password} // Trog::Util::uuid();
+    return Trog::Auth::add_change_request( type => 'reset_pass', user => $user, secret => $newpass, token => $token );
+}
+
+sub do_totp_clear($query) {
+    my $user = $query->{username};
+    # User Does not exist
+    return Trog::Routes::HTML::forbidden($query) if !Trog::Auth::user_exists($user);
+    # User exists, but is not logged in this session
+    return Trog::Routes::HTML::forbidden($query) if !$query->{user} && Trog::Auth::user_has_session($user);
+
+    my $token = Trog::Util::uuid();
+    return Trog::Auth::add_change_request( type => 'clear_totp', user => $user, token => $token );
+}
+
 sub _get_series ( $edit = 0 ) {
     my @series = $data->get(
         acls  => [qw{public}],
@@ -1166,7 +1243,7 @@ sub sitemap ($query) {
 
         # Return the map of static routes
         $route_type = 'Static Routes';
-        @to_map     = grep { !defined $routes{$_}->{captures} && $_ !~ m/^default|login|auth$/ && !$routes{$_}->{auth} } keys(%routes);
+        @to_map     = grep { !defined $routes{$_}->{captures} && !$routes{$_}->{auth} && !$routes{$_}->{noindex} } keys(%routes);
     }
     elsif ( !$query->{map} ) {
 

+ 22 - 0
lib/Trog/Routes/JSON.pm

@@ -8,7 +8,10 @@ use feature qw{signatures state};
 
 use Clone qw{clone};
 use JSON::MaybeXS();
+
 use Trog::Config();
+use Trog::Auth();
+use Trog::Routes::HTML();
 
 my $conf = Trog::Config::get();
 
@@ -32,6 +35,12 @@ our %routes = (
         callback   => \&version,
         parameters => [],
     },
+    '/api/auth_change_request' => {
+        method     => 'POST',
+        callback   => \&process_auth_change_request,
+        parameters => ['token'],
+        noindex    => 1,
+    },
 );
 
 # Clone / redact for catalog
@@ -70,4 +79,17 @@ sub webmanifest ($query) {
     return [ 200, $headers, [$content] ];
 }
 
+sub process_auth_change_request($query) {
+    my $token = $query->{token};
+    return Trog::Routes::HTML::forbidden($query) if !Trog::Auth::change_request_exists($token);
+
+    my $msg = Trog::Auth::process_change_request($token);
+    return Trog::Routes::HTML::forbidden($query) unless $msg;
+    return Trog::Renderer->render(
+        code => 200,
+        message => $msg,
+        result  => 'success',
+    );
+}
+
 1;

+ 5 - 0
lib/Trog/Utils.pm

@@ -6,6 +6,7 @@ use warnings;
 no warnings 'experimental';
 use feature qw{signatures};
 
+use UUID::Tiny();
 use HTTP::Tiny::UNIX();
 use Trog::Log qw{WARN};
 use Trog::Config();
@@ -37,4 +38,8 @@ sub restart_parent ( $env ) {
     kill 'HUP', $parent;
 }
 
+sub uuid {
+    return UUID::Tiny::create_uuid_as_string( UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS );
+}
+
 1;

+ 10 - 1
schema/auth.schema

@@ -2,7 +2,8 @@ CREATE TABLE IF NOT EXISTS user (
     name TEXT NOT NULL UNIQUE,
     salt TEXT NOT NULL,
     hash TEXT NOT NULL,
-    totp_secret TEXT DEFAULT NULL
+    totp_secret TEXT DEFAULT NULL,
+    contact_email TEXT DEFAULT NULL
 );
 
 CREATE TABLE IF NOT EXISTS session (
@@ -18,3 +19,11 @@ CREATE TABLE IF NOT EXISTS user_acl (
     username TEXT NOT NULL UNIQUE REFERENCES user(name) ON DELETE CASCADE,
     acl TEXT NOT NULL
 );
+
+CREATE TABLE IF NOT EXISTS change_request (
+    username TEXT NOT NULL REFERENCES user(name) ON DELETE CASCADE,
+    type     TEXT NOT NULL,
+    token    TEXT PRIMARY KEY UNIQUE,
+    secret   TEXT,
+    processed NUMERIC DEFAULT 0
+);

+ 7 - 0
www/styles/login.css

@@ -51,3 +51,10 @@ input[type="submit"] {
     background-color: #333;
     color: white;
 }
+
+#resetpass {
+    text-align: center;
+    display: block;
+    width: 100%;
+    margin-top: 1rem;
+}

+ 4 - 0
www/templates/html/login.tx

@@ -30,5 +30,9 @@
 : }
       <input type="submit" id="maximumGo" value="<: $btnmsg :>"></input>
     </form>
+    <div id="resetpass">
+        <a href="/request_password_reset">Reset Password</a>
+    </div>
+
 </div>
 : include "components/footer.tx";

+ 13 - 0
www/templates/resetpass.tx

@@ -0,0 +1,13 @@
+<h2>Reset Authentication Details</h2>
+
+<form id="resetpass" action="/request_password_reset" method="POST">
+    <label for="username">Username:</label>
+    <input type="text" class="cooltext" name="username" placeholder="DrBoomer" />
+    <input type="submit" value="Reset Password">
+</form>
+
+<form id="resettotp" action="/request_totp_clear" method="POST">
+    <label for="username">Username:</label>
+    <input type="text" class="cooltext" name="username" placeholder="WhoWasPhone" />
+    <input type="submit" value="Reset TOTP">
+</form>

+ 3 - 2
www/templates/text/robots.tx

@@ -1,11 +1,12 @@
 User-agent: *
 Sitemap: http://<: $domain :>/sitemap_index.xml.gz
-Disallow: /config
 Disallow: /login
 Disallow: /auth
+Disallow: /request_password_reset
+Disallow: /api
 Disallow: /json
-Disallow: /sitemap
 Disallow: /themes
+Disallow: /img
 Disallow: /templates
 Disallow: /scripts
 Disallow: /styles