Ver Fonte

Fix #275: Add the ability to use TOTP 2-Factor Authentication.

George Baugh há 2 anos atrás
pai
commit
29eab2fdcd

+ 1 - 0
.gitignore

@@ -24,3 +24,4 @@ MYMETA.yml
 MYMETA.json
 node_modules/
 www/statics/
+totp/

+ 1 - 0
Makefile

@@ -11,6 +11,7 @@ install:
 	test -d data/files || mkdir -p data/files
 	test -d www/assets || mkdir -p www/assets
 	test -d www/statics || mkdir -p www/statics
+	test -d totp/ || mkdir -p totp
 	$(RM) pod2htmd.tmp;
 
 .PHONY: install-service

+ 3 - 1
Makefile.PL

@@ -16,6 +16,7 @@ WriteMakefile(
     },
   },
   PREREQ_PM => {
+    'Authen::TOTP'           => '0',
     'CGI::Cookie'            => '0',
     'Capture::Tiny'          => '0',
     'Carp'                   => '0',
@@ -33,6 +34,7 @@ WriteMakefile(
     'HTML::SocialMeta'       => '0',
     'HTTP::Body'             => '0',
     'IO::String'             => '0',
+    'Imager::QRCode'         => '0',
     'JSON::MaybeXS'          => '0',
     'List::Util'             => '0',
     'Mojo::File'             => '0',
@@ -51,7 +53,7 @@ WriteMakefile(
     'IO::Compress::Brotli'   => '0',
     'IO::Compress::Gzip'     => '0',
     'IO::Compress::Deflate'  => '0',
-    'HTTP::Parser::XS' => '0',
+    'HTTP::Parser::XS'       => '0',
   },
   test => {TESTS => 't/*.t'}
 );

+ 21 - 0
bin/migrate4.pl

@@ -0,0 +1,21 @@
+#!/usr/bin/env perl
+
+# Migrate to 2FA
+
+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 totp_secret TEXT DEFAULT NULL;");

+ 2 - 0
config/default.cfg

@@ -1,3 +1,5 @@
 [general]
     data_model=FlatFile
     title=tCMS
+[totp]
+    secret=OverrideMeInYourConfigPlease!

+ 2 - 0
lib/TCMS.pm

@@ -177,6 +177,7 @@ sub app {
     }
 
     return _serve("www/$path", $start, $streaming, \@ranges, $last_fetch, $deflate) if -f "www/$path";
+	return _serve("totp/$path", $start, $streaming, \@ranges, $last_fetch, $deflate) if -f "totp/$path" && $active_user;
 
     #Handle regex/capture routes
     if (!exists $routes{$path}) {
@@ -197,6 +198,7 @@ sub app {
     $query->{deflate} = $deflate;
     $query->{user}    = $active_user;
 
+    return _forbidden($query) if $routes{$path}{auth} && !$active_user;
     return _notfound($query) unless exists $routes{$path};
     return _badrequest($query) unless grep { $env->{REQUEST_METHOD} eq $_ } ($routes{$path}{method} || '','HEAD');
 

+ 91 - 7
lib/Trog/Auth.pm

@@ -4,10 +4,12 @@ use strict;
 use warnings;
 
 no warnings 'experimental';
-use feature qw{signatures};
+use feature qw{signatures state};
 
 use UUID::Tiny ':std';
 use Digest::SHA 'sha256';
+use Authen::TOTP;
+use Imager::QRCode;
 use Trog::SQLite;
 
 =head1 Trog::Auth
@@ -50,9 +52,79 @@ sub acls4user($username) {
     return () unless ref $records eq 'ARRAY' && @$records;
     my @acls = map { $_->{acl} } @$records;
     return \@acls;
- }
+}
+
+=head2 totp(user)
+
+Enable TOTP 2fa for the specified user, or if already enabled return the existing info.
+Returns a QR code and URI for pasting into authenticator apps.
+
+=cut
+
+sub totp($user, $domain) {
+	my $totp = _totp();
+	my $dbh  = _dbh();
+
+	my $failure = 0;
+	my $message = "TOTP Secret generated successfully.";
+
+	# Make sure we re-generate the same one in case the user forgot.
+	my $secret;
+    my $worked = $dbh->selectall_arrayref("SELECT totp_secret FROM user WHERE name = ?", { Slice => {} }, $user);
+    if ( ref $worked eq 'ARRAY' && @$worked) {
+    	$secret = $worked->[0]{totp_secret};
+	}
+	$failure = -1 if $secret;
+
+	my $uri = $totp->generate_otp(
+		user   => "$user\@$domain",
+		issuer => $domain,
+		period => 60,
+		$secret ? ( secret => $secret ) : (),
+	);
+
+	if (!$secret) {
+		$secret = $totp->secret();
+		$dbh->do("UPDATE user SET totp_secret=? WHERE name=?", undef, $secret, $user) or return (undef, undef, 1, "Failed to store TOTP secret.");
+	}
+
+	# This is subsequently served via authenticated _serve() in TCMS.pm
+	my $qr = "$user\@$domain.bmp";
+	if (!-f "totp/$qr") {
+		my $qrcode = Imager::QRCode->new(
+			  size          => 4,
+			  margin        => 3,
+			  level         => 'L',
+			  casesensitive => 1,
+			  lightcolor    => Imager::Color->new(255, 255, 255),
+			  darkcolor     => Imager::Color->new(0, 0, 0),
+		);
+
+		my $img = $qrcode->plot($uri);
+		$img->write(file => "totp/$qr", type => "bmp") or return(undef, undef, 1, "Could not write totp/$qr: ".$img->errstr);
+	}
+	return ($uri, $qr, $failure, $message);
+}
+
+sub _totp {
+    state $totp;
+    if (!$totp) {
+        my $cfg = Trog::Config->get();
+        my $global_secret = $cfg->param('totp.secret');
+        die "Global secret must be set in tCMS configuration totp section!" unless $global_secret;
+        $totp = Authen::TOTP->new( secret => $global_secret );
+        die "Cannot instantiate TOTP client!" unless $totp;
+    }
+	return $totp;
+}
 
-=head2 mksession(user, pass) = STRING
+sub clear_totp {
+    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
+}
+
+=head2 mksession(user, pass, token) = STRING
 
 Create a session for the user and waste all other sessions.
 
@@ -60,15 +132,27 @@ Returns a session ID, or blank string in the event the user does not exist or in
 
 =cut
 
-sub mksession ($user,$pass) {
-    my $dbh = _dbh();
+sub mksession ($user, $pass, $token) {
+    my $dbh  = _dbh();
+	my $totp = _totp();
+
+    # Check the password
     my $records = $dbh->selectall_arrayref("SELECT salt FROM user WHERE name = ?", { Slice => {} }, $user);
     return '' unless ref $records eq 'ARRAY' && @$records;
     my $salt = $records->[0]->{salt};
     my $hash = sha256($pass.$salt);
-    my $worked = $dbh->selectall_arrayref("SELECT name FROM user WHERE hash=? AND name = ?", { Slice => {} }, $hash, $user);
+    my $worked = $dbh->selectall_arrayref("SELECT name, totp_secret FROM user WHERE hash=? AND name = ?", { Slice => {} }, $hash, $user);
     return '' unless ref $worked eq 'ARRAY' && @$worked;
-    my $uid = $worked->[0]->{name};
+    my $uid = $worked->[0]{name};
+    my $secret = $worked->[0]{totp_secret};
+
+    # Validate the 2FA Token.  If we have no secret, allow login so they can see their QR code, and subsequently re-auth.
+    if ($secret) {
+        my $rc   = $totp->validate_otp(otp => $token, secret => $secret, tolerance => 1);
+        return '' unless $rc;
+    }
+
+    # Issue cookie
     my $uuid = create_uuid_as_string(UUID_V1, UUID_NS_DNS);
     $dbh->do("INSERT OR REPLACE INTO session (id,username) VALUES (?,?)", undef, $uuid, $uid) or return '';
     return $uuid;

+ 4 - 1
lib/Trog/Config.pm

@@ -2,6 +2,7 @@ package Trog::Config;
 
 use strict;
 use warnings;
+use feature qw{state};
 
 use Config::Simple;
 
@@ -12,13 +13,15 @@ A thin wrapper around Config::Simple which reads the configuration from the appr
 =head2 Trog::Config::get() = Config::Simple
 
 Returns a configuration object that will be used by server.psgi, the data model and Routing modules.
+Memoized, so you will need to HUP the children on config changes.
 
 =cut
 
 our $home_cfg = "config/main.cfg";
 
 sub get {
-    my $cf;
+    state $cf;
+    return $cf if $cf;
     $cf = Config::Simple->new($home_cfg) if -f $home_cfg;
     return $cf if $cf;
     $cf = Config::Simple->new('config/default.cfg');

+ 47 - 15
lib/Trog/Routes/HTML.pm

@@ -21,6 +21,7 @@ use File::Basename qw{dirname};
 
 use Trog::Utils;
 use Trog::Config;
+use Trog::Auth;
 use Trog::Data;
 
 my $conf = Trog::Config::get();
@@ -76,6 +77,11 @@ our %routes = (
         method   => 'POST',
         callback => \&Trog::Routes::HTML::login,
     },
+    '/totp' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::totp,
+    },
     '/post/save' => {
         method   => 'POST',
         auth     => 1,
@@ -378,6 +384,29 @@ sub setup ($query) {
     });
 }
 
+=head2 totp
+
+Enable 2 factor auth via TOTP for the currently authenticated user.
+Returns a page with a QR code & TOTP uri for pasting into your authenticator app of choice.
+
+=cut
+
+sub totp($query) {
+    my $active_user = $query->{user};
+	my $domain      = $query->{domain}; 
+    my ($uri, $qr, $failure, $message) = Trog::Auth::totp($active_user, $domain);
+
+    return finish_render('totp.tx', {
+        title       => 'Enable TOTP 2-Factor Auth',
+        theme_dir   => $td,
+        uri         => $uri,
+		qr          => $qr,
+		failure     => $failure,
+		message     => $message,
+        stylesheets => _build_themed_styles('post.css'),
+    });
+}
+
 =head2 login
 
 Sets the user cookie if the provided user exists, or sets up the user as an admin with the provided credentials in the event that no users exist.
@@ -400,6 +429,7 @@ sub login ($query) {
     my $btnmsg = $hasusers ? "Log In" : "Register";
 
     my @headers;
+	my $has_totp = 0;
     if ($query->{username} && $query->{password}) {
         if (!$hasusers) {
             # Make the first user
@@ -412,7 +442,7 @@ sub login ($query) {
         }
 
         $query->{failed} = 1;
-        my $cookie = Trog::Auth::mksession($query->{username}, $query->{password});
+        my $cookie = Trog::Auth::mksession($query->{username}, $query->{password}, $query->{token});
         if ($cookie) {
             # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
             my $secure = '';
@@ -426,13 +456,13 @@ sub login ($query) {
 
     $query->{failed} //= -1;
     return finish_render('login.tx', {
-        title         => 'tCMS 2 ~ Login',
-        to            => $query->{to},
-        failure => int( $query->{failed} ),
-        message => int( $query->{failed} ) < 1 ? "Login Successful, Redirecting..." : "Login Failed.",
-        btnmsg        => $btnmsg,
-        stylesheets   => _build_themed_styles('login.css'),
-        theme_dir     => $td,
+        title       => 'tCMS 2 ~ Login',
+        to          => $query->{to},
+        failure		=> int( $query->{failed} ),
+        message		=> int( $query->{failed} ) < 1 ? "Login Successful, Redirecting..." : "Login Failed.",
+        btnmsg      => $btnmsg,
+        stylesheets => _build_themed_styles('login.css'),
+        theme_dir   => $td,
     }, @headers);
 }
 
@@ -532,18 +562,17 @@ sub config ($query) {
     my $js    = _build_themed_scripts('post.js');
 
     $query->{failure} //= -1;
-    my @series = _get_series(1);
 
     return finish_render('config.tx', {
         title              => 'Configure tCMS',
         theme_dir          => $td,
         stylesheets        => $css,
         scripts            => $js,
-        categories         => \@series,
         themes             => _get_themes() || [],
         data_models        => _get_data_models(),
         current_theme      => $conf->param('general.theme') // '',
         current_data_model => $conf->param('general.data_model') // 'DUMMY',
+        totp_secret        => $conf->param('totp.secret'),
         message     => $query->{message},
         failure     => $query->{failure},
         to          => '/config',
@@ -587,8 +616,14 @@ sub config_save ($query) {
     return see_also('/login') unless $query->{user};
     return Trog::Routes::HTML::forbidden($query) unless grep { $_ eq 'admin' } @{$query->{user_acls}};
 
-    $conf->param( 'general.theme',      $query->{theme} )      if defined $query->{theme};
-    $conf->param( 'general.data_model', $query->{data_model} ) if $query->{data_model};
+    $conf->param( 'general.theme',      $query->{theme} )       if defined $query->{theme};
+    $conf->param( 'general.data_model', $query->{data_model} )  if $query->{data_model};
+
+    # Erase all TOTP secrets in the event we change the global secret
+    if ($query->{totp_secret}) {
+        $conf->param( 'totp.secret',        $query->{totp_secret} );
+        Trog::Auth::clear_totp();
+    }
 
     $query->{failure} = 1;
     $query->{message} = "Failed to save configuration!";
@@ -1192,13 +1227,10 @@ sub manual ($query) {
     return notfound($query) unless -f "lib/$infile";
     my $content = capture { Pod::Html::pod2html(qw{--podpath=lib --podroot=.},"--infile=lib/$infile") };
 
-    my @series = _get_series(1);
-
     return finish_render('manual.tx', {
         title       => 'tCMS Manual',
         theme_dir   => $td,
         content     => $content,
-        categories  => \@series,
         stylesheets => _build_themed_styles('post.css'),
     });
 }

+ 2 - 1
schema/auth.schema

@@ -1,7 +1,8 @@
 CREATE TABLE IF NOT EXISTS user (
     name TEXT NOT NULL UNIQUE,
     salt TEXT NOT NULL,
-    hash TEXT NOT NULL
+    hash TEXT NOT NULL,
+    totp_secret TEXT DEFAULT NULL
 );
 
 CREATE TABLE IF NOT EXISTS session (

+ 4 - 0
www/templates/config.tx

@@ -24,6 +24,10 @@ If for example, you use mysql it will have to rely on either a local server, val
         : }
     </select>
     </div>
+    <div>
+    Global TOTP Secret:
+    <input class="cooltext" type="text" name="totp_secret" value="<: $totp_secret :>" />
+    </div>
     <br />
     <input type="submit" class="coolbutton" value="Commit Changes" />
 </form>

+ 6 - 0
www/templates/login.tx

@@ -19,6 +19,12 @@
         <input required name="password" id="password" placeholder="hunter2" value="" type="password"></input>
       </div>
       <br />
+      TOTP 2FA code (ignore unless enabled)<br />
+      <div class="input-group">
+        <label for="token">🔒</label>
+        <input name="token" id="token" placeholder="7779311" value="" type="password"></input>
+      </div>
+      <br />
       <input type="submit" id="maximumGo" value="<: $btnmsg :>"></input>
     </form>
 </div>

+ 1 - 0
www/templates/sysbar.tx

@@ -4,6 +4,7 @@
         <a href="/"            title="Back home"     class="topbar">Home</a>
         <a href="/config"      title="Configuration" class="topbar">Settings</a>
         <a href="/manual"      title="Manual"        class="topbar">Manual</a>
+        <a href="/totp"        title="TOTP"          class="topbar">Enable/View TOTP 2fa</a>
         <a href="/logout"      title="Logout"        class="topbar">🚪</a>
     </span>
 </div>

+ 8 - 0
www/templates/totp.tx

@@ -0,0 +1,8 @@
+: include "sysbar.tx";
+: include "jsalert.tx";
+<div id="backoffice">
+    Use the following URI, or scan the below QR code into your authenticator Application of choice:<br />
+    <: $uri :>
+    <br />
+    <img src="/<: $qr :>" alt="TOTP QR Code" />
+</div>