Эх сурвалжийг харах

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

George Baugh 2 жил өмнө
parent
commit
29eab2fdcd

+ 1 - 0
.gitignore

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

+ 1 - 0
Makefile

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

+ 3 - 1
Makefile.PL

@@ -16,6 +16,7 @@ WriteMakefile(
     },
     },
   },
   },
   PREREQ_PM => {
   PREREQ_PM => {
+    'Authen::TOTP'           => '0',
     'CGI::Cookie'            => '0',
     'CGI::Cookie'            => '0',
     'Capture::Tiny'          => '0',
     'Capture::Tiny'          => '0',
     'Carp'                   => '0',
     'Carp'                   => '0',
@@ -33,6 +34,7 @@ WriteMakefile(
     'HTML::SocialMeta'       => '0',
     'HTML::SocialMeta'       => '0',
     'HTTP::Body'             => '0',
     'HTTP::Body'             => '0',
     'IO::String'             => '0',
     'IO::String'             => '0',
+    'Imager::QRCode'         => '0',
     'JSON::MaybeXS'          => '0',
     'JSON::MaybeXS'          => '0',
     'List::Util'             => '0',
     'List::Util'             => '0',
     'Mojo::File'             => '0',
     'Mojo::File'             => '0',
@@ -51,7 +53,7 @@ WriteMakefile(
     'IO::Compress::Brotli'   => '0',
     'IO::Compress::Brotli'   => '0',
     'IO::Compress::Gzip'     => '0',
     'IO::Compress::Gzip'     => '0',
     'IO::Compress::Deflate'  => '0',
     'IO::Compress::Deflate'  => '0',
-    'HTTP::Parser::XS' => '0',
+    'HTTP::Parser::XS'       => '0',
   },
   },
   test => {TESTS => 't/*.t'}
   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]
 [general]
     data_model=FlatFile
     data_model=FlatFile
     title=tCMS
     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("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
     #Handle regex/capture routes
     if (!exists $routes{$path}) {
     if (!exists $routes{$path}) {
@@ -197,6 +198,7 @@ sub app {
     $query->{deflate} = $deflate;
     $query->{deflate} = $deflate;
     $query->{user}    = $active_user;
     $query->{user}    = $active_user;
 
 
+    return _forbidden($query) if $routes{$path}{auth} && !$active_user;
     return _notfound($query) unless exists $routes{$path};
     return _notfound($query) unless exists $routes{$path};
     return _badrequest($query) unless grep { $env->{REQUEST_METHOD} eq $_ } ($routes{$path}{method} || '','HEAD');
     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;
 use warnings;
 
 
 no warnings 'experimental';
 no warnings 'experimental';
-use feature qw{signatures};
+use feature qw{signatures state};
 
 
 use UUID::Tiny ':std';
 use UUID::Tiny ':std';
 use Digest::SHA 'sha256';
 use Digest::SHA 'sha256';
+use Authen::TOTP;
+use Imager::QRCode;
 use Trog::SQLite;
 use Trog::SQLite;
 
 
 =head1 Trog::Auth
 =head1 Trog::Auth
@@ -50,9 +52,79 @@ sub acls4user($username) {
     return () unless ref $records eq 'ARRAY' && @$records;
     return () unless ref $records eq 'ARRAY' && @$records;
     my @acls = map { $_->{acl} } @$records;
     my @acls = map { $_->{acl} } @$records;
     return \@acls;
     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.
 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
 =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);
     my $records = $dbh->selectall_arrayref("SELECT salt FROM user WHERE name = ?", { Slice => {} }, $user);
     return '' unless ref $records eq 'ARRAY' && @$records;
     return '' unless ref $records eq 'ARRAY' && @$records;
     my $salt = $records->[0]->{salt};
     my $salt = $records->[0]->{salt};
     my $hash = sha256($pass.$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;
     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);
     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 '';
     $dbh->do("INSERT OR REPLACE INTO session (id,username) VALUES (?,?)", undef, $uuid, $uid) or return '';
     return $uuid;
     return $uuid;

+ 4 - 1
lib/Trog/Config.pm

@@ -2,6 +2,7 @@ package Trog::Config;
 
 
 use strict;
 use strict;
 use warnings;
 use warnings;
+use feature qw{state};
 
 
 use Config::Simple;
 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
 =head2 Trog::Config::get() = Config::Simple
 
 
 Returns a configuration object that will be used by server.psgi, the data model and Routing modules.
 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
 =cut
 
 
 our $home_cfg = "config/main.cfg";
 our $home_cfg = "config/main.cfg";
 
 
 sub get {
 sub get {
-    my $cf;
+    state $cf;
+    return $cf if $cf;
     $cf = Config::Simple->new($home_cfg) if -f $home_cfg;
     $cf = Config::Simple->new($home_cfg) if -f $home_cfg;
     return $cf if $cf;
     return $cf if $cf;
     $cf = Config::Simple->new('config/default.cfg');
     $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::Utils;
 use Trog::Config;
 use Trog::Config;
+use Trog::Auth;
 use Trog::Data;
 use Trog::Data;
 
 
 my $conf = Trog::Config::get();
 my $conf = Trog::Config::get();
@@ -76,6 +77,11 @@ our %routes = (
         method   => 'POST',
         method   => 'POST',
         callback => \&Trog::Routes::HTML::login,
         callback => \&Trog::Routes::HTML::login,
     },
     },
+    '/totp' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::totp,
+    },
     '/post/save' => {
     '/post/save' => {
         method   => 'POST',
         method   => 'POST',
         auth     => 1,
         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
 =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.
 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 $btnmsg = $hasusers ? "Log In" : "Register";
 
 
     my @headers;
     my @headers;
+	my $has_totp = 0;
     if ($query->{username} && $query->{password}) {
     if ($query->{username} && $query->{password}) {
         if (!$hasusers) {
         if (!$hasusers) {
             # Make the first user
             # Make the first user
@@ -412,7 +442,7 @@ sub login ($query) {
         }
         }
 
 
         $query->{failed} = 1;
         $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) {
         if ($cookie) {
             # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
             # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
             my $secure = '';
             my $secure = '';
@@ -426,13 +456,13 @@ sub login ($query) {
 
 
     $query->{failed} //= -1;
     $query->{failed} //= -1;
     return finish_render('login.tx', {
     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);
     }, @headers);
 }
 }
 
 
@@ -532,18 +562,17 @@ sub config ($query) {
     my $js    = _build_themed_scripts('post.js');
     my $js    = _build_themed_scripts('post.js');
 
 
     $query->{failure} //= -1;
     $query->{failure} //= -1;
-    my @series = _get_series(1);
 
 
     return finish_render('config.tx', {
     return finish_render('config.tx', {
         title              => 'Configure tCMS',
         title              => 'Configure tCMS',
         theme_dir          => $td,
         theme_dir          => $td,
         stylesheets        => $css,
         stylesheets        => $css,
         scripts            => $js,
         scripts            => $js,
-        categories         => \@series,
         themes             => _get_themes() || [],
         themes             => _get_themes() || [],
         data_models        => _get_data_models(),
         data_models        => _get_data_models(),
         current_theme      => $conf->param('general.theme') // '',
         current_theme      => $conf->param('general.theme') // '',
         current_data_model => $conf->param('general.data_model') // 'DUMMY',
         current_data_model => $conf->param('general.data_model') // 'DUMMY',
+        totp_secret        => $conf->param('totp.secret'),
         message     => $query->{message},
         message     => $query->{message},
         failure     => $query->{failure},
         failure     => $query->{failure},
         to          => '/config',
         to          => '/config',
@@ -587,8 +616,14 @@ sub config_save ($query) {
     return see_also('/login') unless $query->{user};
     return see_also('/login') unless $query->{user};
     return Trog::Routes::HTML::forbidden($query) unless grep { $_ eq 'admin' } @{$query->{user_acls}};
     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->{failure} = 1;
     $query->{message} = "Failed to save configuration!";
     $query->{message} = "Failed to save configuration!";
@@ -1192,13 +1227,10 @@ sub manual ($query) {
     return notfound($query) unless -f "lib/$infile";
     return notfound($query) unless -f "lib/$infile";
     my $content = capture { Pod::Html::pod2html(qw{--podpath=lib --podroot=.},"--infile=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', {
     return finish_render('manual.tx', {
         title       => 'tCMS Manual',
         title       => 'tCMS Manual',
         theme_dir   => $td,
         theme_dir   => $td,
         content     => $content,
         content     => $content,
-        categories  => \@series,
         stylesheets => _build_themed_styles('post.css'),
         stylesheets => _build_themed_styles('post.css'),
     });
     });
 }
 }

+ 2 - 1
schema/auth.schema

@@ -1,7 +1,8 @@
 CREATE TABLE IF NOT EXISTS user (
 CREATE TABLE IF NOT EXISTS user (
     name TEXT NOT NULL UNIQUE,
     name TEXT NOT NULL UNIQUE,
     salt TEXT NOT NULL,
     salt TEXT NOT NULL,
-    hash TEXT NOT NULL
+    hash TEXT NOT NULL,
+    totp_secret TEXT DEFAULT NULL
 );
 );
 
 
 CREATE TABLE IF NOT EXISTS session (
 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>
     </select>
     </div>
     </div>
+    <div>
+    Global TOTP Secret:
+    <input class="cooltext" type="text" name="totp_secret" value="<: $totp_secret :>" />
+    </div>
     <br />
     <br />
     <input type="submit" class="coolbutton" value="Commit Changes" />
     <input type="submit" class="coolbutton" value="Commit Changes" />
 </form>
 </form>

+ 6 - 0
www/templates/login.tx

@@ -19,6 +19,12 @@
         <input required name="password" id="password" placeholder="hunter2" value="" type="password"></input>
         <input required name="password" id="password" placeholder="hunter2" value="" type="password"></input>
       </div>
       </div>
       <br />
       <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>
       <input type="submit" id="maximumGo" value="<: $btnmsg :>"></input>
     </form>
     </form>
 </div>
 </div>

+ 1 - 0
www/templates/sysbar.tx

@@ -4,6 +4,7 @@
         <a href="/"            title="Back home"     class="topbar">Home</a>
         <a href="/"            title="Back home"     class="topbar">Home</a>
         <a href="/config"      title="Configuration" class="topbar">Settings</a>
         <a href="/config"      title="Configuration" class="topbar">Settings</a>
         <a href="/manual"      title="Manual"        class="topbar">Manual</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>
         <a href="/logout"      title="Logout"        class="topbar">🚪</a>
     </span>
     </span>
 </div>
 </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>