Browse Source

Wow, adding password reset URLs was a lot of work!

George Baugh 2 years ago
parent
commit
9b6085c8d2

+ 2 - 0
Makefile.PL

@@ -62,6 +62,8 @@ WriteMakefile(
     'FindBin::libs'             => '0',
     'Carp::Always'              => '0',
     'HTTP::Tiny::UNIX'          => '0',
+    'Email::MIME'               => '0',
+    'Email::Sender::Simple'     => '0',
   },
   test => {TESTS => 't/*.t'}
 );

+ 3 - 2
bin/tcms-useradd

@@ -7,11 +7,12 @@ use FindBin::libs;
 
 use Trog::Auth;
 
-my ($user, $pass) = @ARGV;
+my ($user, $pass, $contactemail) = @ARGV;
 
 # TODO better arg handling, etc
 die "User must be first arg" unless $user;
 die "Password must be second arg" unless $pass;
+die "contact email must be third arg" unless $contactemail;
 
 Trog::Auth::killsession($user);
-Trog::Auth::useradd($user, $pass, ['admin']);
+Trog::Auth::useradd($user, $pass, ['admin'], $contactemail);

+ 36 - 6
lib/Trog/Auth.pm

@@ -8,6 +8,7 @@ use feature qw{signatures state};
 
 use FindBin::libs;
 
+use Ref::Util qw{is_arrayref};
 use UUID::Tiny ':std';
 use Digest::SHA 'sha256';
 use Authen::TOTP;
@@ -69,6 +70,19 @@ sub user_exists ($user) {
     return 1;
 }
 
+=head2 email4user(STRING username) = STRING
+
+Return the associated contact email for the user.
+
+=cut
+
+sub email4user ($user) {
+    my $dbh  = _dbh();
+    my $rows = $dbh->selectall_arrayref( "SELECT contact_email FROM user WHERE name=?", { Slice => {} }, $user );
+    return '' unless ref $rows eq 'ARRAY' && @$rows;
+    return $rows->[0]{contact_email};
+}
+
 =head2 acls4user(STRING username) = ARRAYREF
 
 Return the list of ACLs belonging to the user.
@@ -263,11 +277,16 @@ Returns True or False (likely false when user already exists).
 
 =cut
 
-sub useradd ( $user, $pass, $acls ) {
+sub useradd ( $user, $pass, $acls, $contactemail ) {
+	die "No username set!" unless $user;
+	die "No password set for user!" unless $pass;
+	die "ACLs must be array" unless is_arrayref($acls);
+	die "No contact email set for user!" unless $contactemail;
+
     my $dbh  = _dbh();
     my $salt = create_uuid();
     my $hash = sha256( $pass . $salt );
-    my $res  = $dbh->do( "INSERT OR REPLACE INTO user (name,salt,hash) VALUES (?,?,?)", undef, $user, $salt, $hash );
+    my $res  = $dbh->do( "INSERT OR REPLACE INTO user (name,salt,hash,contact_email) VALUES (?,?,?,?)", undef, $user, $salt, $hash, $contactemail );
     return unless $res && ref $acls eq 'ARRAY';
 
     #XXX this is clearly not normalized with an ACL mapping table, will be an issue with large number of users
@@ -285,18 +304,24 @@ sub add_change_request ( %args ) {
 
 sub process_change_request ( $token ) {
     my $dbh  = _dbh();
-    my $rows = $dbh->selectall_arrayref( "SELECT username, type FROM change_request WHERE token=?", { Slice => {} }, $token );
+    my $rows = $dbh->selectall_arrayref( "SELECT username, type, secret, contact_email FROM change_request_full WHERE processed=0 AND token=?", { Slice => {} }, $token );
     return 0 unless ref $rows eq 'ARRAY' && @$rows;
 
-    my $type = $rows->[0]{type};
     my $user = $rows->[0]{username};
+    my $type = $rows->[0]{type};
     my $secret = $rows->[0]{secret};
+    my $contactemail = $rows->[0]{contact_email};
+
     state %dispatch = (
         reset_pass => sub {
             my ($user, $pass) = @_;
-            useradd( $user, $pass ) or do {
+			#XXX The fact that this is an INSERT OR REPLACE means all the entries in change_request for this user will get cascade wiped.  Which is good, as the secrets aren't salted.
+			# This is also why we have to snag the user's ACLs or they will be wiped.
+			my @acls = acls4user($user);
+            useradd( $user, $pass, \@acls, $contactemail ) or do {
                return ''; 
             };
+            killsession($user);
             return "Password set to $pass for $user";
         },
         clear_totp => sub {
@@ -304,10 +329,15 @@ sub process_change_request ( $token ) {
             clear_totp($user) or do {
                 return '';
             };
+            killsession($user);
             return "TOTP auth turned off for $user";
         },
     );
-    return $dispatch{$type}->($user, $secret);
+    my $res = $dispatch{$type}->($user, $secret);
+    $dbh->do("UPDATE change_request SET processed=1 WHERE token=?", undef, $token) or do {
+        FATAL("Could not set job with token $token to completed!");
+    };
+    return $res;
 }
 
 # Ensure the db schema is OK, and give us a handle

+ 69 - 0
lib/Trog/Email.pm

@@ -0,0 +1,69 @@
+package Trog::Email;
+
+use strict;
+use warnings;
+
+no warnings qw{experimental};
+use feature qw{signatures};
+
+use Email::MIME;
+use Email::Sender::Simple;
+
+use Trog::Auth;
+use Trog::Log qw{:all};
+use Trog::Renderer;
+
+sub contact ($user, $from, $subject, $data) {
+    my $email = Trog::Auth::email4user($user);
+    die "No contact email set for user $user!" unless $email;
+
+	my $render = Trog::Renderer->render(
+		contenttype => 'multipart/related',
+		code		 => 200,
+		template     => $data->{template},
+		data         => {
+			method       => 'EMAIL',
+			# Important - this will prevent caching
+			route        => '',
+			%$data,
+		},
+	);
+
+	my $text = $render->{text}[2][0];
+	my $html = $render->{html}[2][0];
+
+	my @parts = (
+		Email::MIME->create(
+			attributes => {
+				content_type => "text/plain",
+				disposition  => "attachment",
+				charset      => 'UTF-8',
+			},
+			body => $text,
+		),
+		Email::MIME->create(
+			attributes => {
+				content_type => "text/html",
+				disposition => "attachment",
+				charset     => "UTF-8",
+			},
+			body => $html,
+		),
+	);
+
+	my $mail = Email::MIME->create(
+		header_str => [
+			From => $from,
+			To => [$email],
+			Subject => $subject,
+		],
+		parts      => \@parts,
+	);
+
+    Email::Sender::Simple->try_to_send($mail) or do {
+		FATAL("Could not send email from $from to $email!");
+	};
+	return 1;
+}
+
+1;

+ 2 - 0
lib/Trog/Renderer.pm

@@ -16,6 +16,7 @@ use Trog::Renderer::html;
 use Trog::Renderer::json;
 use Trog::Renderer::blob;
 use Trog::Renderer::css;
+use Trog::Renderer::email;
 
 =head1 Trog::Renderer
 
@@ -34,6 +35,7 @@ our %renderers = (
     xml   => \&Trog::Renderer::text::render,
     rss   => \&Trog::Renderer::html::render,
     css   => \&Trog::Renderer::css::render,
+    email => \&Trog::Renderer::email::render,
 );
 
 =head2 Trog::Renderer->render(%options)

+ 28 - 0
lib/Trog/Renderer/email.pm

@@ -0,0 +1,28 @@
+package Trog::Renderer::email;
+
+use strict;
+use warnings;
+
+no warnings 'experimental';
+use feature qw{signatures state};
+
+use parent qw{Trog::Renderer::Base};
+
+use Text::Xslate;
+use Trog::Themes;
+use Trog::Renderer::html;
+
+=head1 Trog::Renderer::email
+
+Render emails with both HTML and email parts, and inline all CSS/JS/Images.
+
+=cut
+
+# TODO inlining
+sub render (%options) {
+    my $text = Trog::Renderer::Base::render(%options, contenttype => 'text/plain');
+	my $html = Trog::Renderer::html::render(%options, contenttype => 'text/html');
+	return { text => $text, html => $html };
+}
+
+1;

+ 4 - 2
lib/Trog/Renderer/json.pm

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

+ 54 - 11
lib/Trog/Routes/HTML.pm

@@ -130,7 +130,7 @@ our %routes = (
         captures => ['module'],
         callback => \&Trog::Routes::HTML::manual,
     },
-    '/request_password_reset' => {
+    '/password_reset' => {
         method => 'GET',
         callback => \&Trog::Routes::HTML::resetpass,
         noindex  => 1,
@@ -145,6 +145,11 @@ our %routes = (
         callback => \&Trog::Routes::HTML::do_totp_clear,
         noindex  => 1,
     },
+    '/processed' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::processed,
+        noindex  => 1,
+    },
     # END FAIL2BAN ROUTES
 
     #TODO transform into posts?
@@ -242,6 +247,7 @@ sub index ( $query, $content = '', $i_styles = [] ) {
 
     my $to_render = $query->{template} // $landing_page;
     $content ||= Trog::Renderer->render( template => $to_render, data => $query, component => 1, contenttype => 'text/html' );
+    return $content if ref $content eq "ARRAY";
 
     my @styles;
     unshift( @styles, qw{embed.css}) if $query->{embed};
@@ -519,9 +525,8 @@ sub login ($query) {
     my $has_totp = 0;
     if ( $query->{username} && $query->{password} ) {
         if ( !$hasusers ) {
-
             # Make the first user
-            Trog::Auth::useradd( $query->{username}, $query->{password}, ['admin'] );
+            Trog::Auth::useradd( $query->{username}, $query->{password}, ['admin'], $query->{contact_email} );
 
             # Add a stub user page and the initial series.
             my $dat = Trog::Data->new($conf);
@@ -721,9 +726,22 @@ sub do_resetpass($query) {
     # 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 );
+    my $token = Trog::Utils::uuid();
+    my $newpass = $query->{password} // Trog::Utils::uuid();
+    my $res = Trog::Auth::add_change_request( type => 'reset_pass', user => $user, secret => $newpass, token => $token );
+	die "Could not add auth change request!" unless $res;
+
+    # If the user is logged in, just do the deed, otherwise send them the token in an email
+    if ($query->{user}) {
+        return see_also("/api/auth_change_request/$token");
+    }
+    Trog::Email::contact(
+		$user,
+		"root\@$query->{domain}",
+		"$query->{domain}: Password reset URL for $user",
+		{ uri => "$query->{scheme}://$query->{domain}/api/auth_change_request/$token", template => 'password_reset.tx' }
+	);
+    return see_also("/processed");
 }
 
 sub do_totp_clear($query) {
@@ -733,8 +751,21 @@ sub do_totp_clear($query) {
     # 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 );
+    my $token = Trog::Utils::uuid();
+    my $res   = Trog::Auth::add_change_request( type => 'clear_totp', user => $user, token => $token );
+	die "Could not add auth change request!" unless $res;
+
+    # If the user is logged in, just do the deed, otherwise send them the token in an email
+    if ($query->{user}) {
+        return see_also("/api/auth_change_request/$token");
+    }
+    Trog::Email::contact(
+		$user,
+		"root\@$query->{domain}",
+		"$query->{domain}: Password reset URL for $user",
+		{ uri => "$query->{scheme}://$query->{domain}/api/auth_change_request/$token", template => 'totp_reset.tx' }
+	);
+    return see_also("/processed");
 }
 
 sub _get_series ( $edit = 0 ) {
@@ -864,14 +895,17 @@ sub profile ($query) {
     return see_also('/login')                    unless $query->{user};
     return Trog::Routes::HTML::forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
 
-    #TODO allow users to do something OTHER than be admins
-    if ( $query->{password} ) {
-        Trog::Auth::useradd( $query->{username}, $query->{password}, ['admin'] );
+    #TODO allow new users to do something OTHER than be admins
+	#TODO allow username changes
+    if ( $query->{password} || $query->{contact_email} ) {
+		my @acls = Trog::Auth::acls4user($query->{username}) || qw{admin};
+        Trog::Auth::useradd( $query->{username}, $query->{password}, \@acls, $query->{contact_email} );
     }
 
     #Make sure it is "self-authored", redact pw
     $query->{user} = delete $query->{username};
     delete $query->{password};
+	delete $query->{contact_email};
 
     return post_save($query);
 }
@@ -1468,6 +1502,15 @@ sub manual ($query) {
     );
 }
 
+sub processed ($query) {
+    return Trog::Routes::HTML::index({
+        title => "Your request has been processed",
+        theme_dir => $Trog::Themes::td,
+    },
+	"Your request has been processed.<br /><br />You will recieve subsequent communications about this matter via means you have provided earlier.",
+	['post.css']);
+}
+
 # basically a file rewrite rule for themes
 sub icon ($query) {
     my $path = $query->{route};

+ 13 - 5
lib/Trog/Routes/JSON.pm

@@ -35,10 +35,11 @@ our %routes = (
         callback   => \&version,
         parameters => [],
     },
-    '/api/auth_change_request' => {
-        method     => 'POST',
+    '/api/auth_change_request/(.*)' => {
+        method     => 'GET',
         callback   => \&process_auth_change_request,
         parameters => ['token'],
+        captures   => ['token'],
         noindex    => 1,
     },
 );
@@ -81,14 +82,21 @@ sub webmanifest ($query) {
 
 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 _render(200,
+        	message => $msg,
+        	result  => 'success',
+    );
+}
+
+sub _render ($code, %data) {
     return Trog::Renderer->render(
         code => 200,
-        message => $msg,
-        result  => 'success',
+		data => \%data,
+		template => 'bogus.tx',
+		contenttype => 'application/json',
     );
 }
 

+ 2 - 1
lib/Trog/Vars.pm

@@ -15,7 +15,8 @@ our %content_types = (
     xml   => "text/xml",
     xsl   => "text/xsl",
     css   => "text/css",
-    rss   => "application/rss+xml"
+    rss   => "application/rss+xml",
+    email => "multipart/related",
 );
 
 our %byct = reverse %Trog::Vars::content_types;

+ 2 - 0
schema/auth.schema

@@ -27,3 +27,5 @@ CREATE TABLE IF NOT EXISTS change_request (
     secret   TEXT,
     processed NUMERIC DEFAULT 0
 );
+
+CREATE VIEW IF NOT EXISTS change_request_full AS SELECT cr.username, cr.type, cr.token, cr.secret, cr.processed, u.contact_email from change_request AS cr JOIN user AS u ON u.name=cr.username;

+ 1 - 0
www/templates/html/components/forms/profile.tx

@@ -25,6 +25,7 @@
     <form class="Submissions" action="/profile" method="POST" enctype="multipart/form-data">
         Username *<br /><input required class="cooltext" type="text" name="username" placeholder="AzureDiamond" value="<: $post.user :>" />
         Password *<br /><input <: $post.user ? '' : 'required' :> class="cooltext" type="password" name="password" placeholder="hunter2" />
+        Contact Email *<br /><input <: $post.user ? '' : 'required' :> class="cooltext" type="text" name="contact_email" placeholder="test@test.test" />
         Avatar *<br /><input class="cooltext" type="file" name="preview_file" />
         : if ( $post.preview ) {
         <input type="hidden" name="preview" value="<: $post.preview :>" />

+ 13 - 1
www/templates/resetpass.tx → www/templates/html/components/resetpass.tx

@@ -1,13 +1,25 @@
 <h2>Reset Authentication Details</h2>
-
+<h3>Reset Password</h3>
 <form id="resetpass" action="/request_password_reset" method="POST">
+: if ($user) {
+    <label for="password">Password:</label>
+    <input type="password" class="cooltext" name="password" placeholder="password123" />
+    <input type="hidden" name="username" value="<: $user :>" />
+: } else {
     <label for="username">Username:</label>
     <input type="text" class="cooltext" name="username" placeholder="DrBoomer" />
+: }
     <input type="submit" value="Reset Password">
 </form>
 
+<br /><br />
+<h3>Turn off TOTP 2fa</h3>
 <form id="resettotp" action="/request_totp_clear" method="POST">
+: if ($user) {
+    <input type="hidden" name="username" value="<: $user :>" />
+: } else {
     <label for="username">Username:</label>
     <input type="text" class="cooltext" name="username" placeholder="WhoWasPhone" />
+: }
     <input type="submit" value="Reset TOTP">
 </form>

+ 12 - 2
www/templates/html/login.tx

@@ -1,6 +1,6 @@
 : include "components/header.tx";
+: include "jsalert.tx";
 <div id="login">
-    : include "jsalert.tx";
     <div>
       <img id="logo" src="/img/icon/favicon.svg" style="float:left" /><span style="font-family:courier;font-size:2rem;">CMS Login</span>
     </div>
@@ -27,12 +27,22 @@
         <input name="token" id="token" placeholder="7779311" value="" type="password"></input>
       </div>
       <br />
+: } else {
+      Contact Email:<br />
+      <div class="input-group">
+        <label for="contact_email">📧</label>
+        <input required name="contact_email" id="contact_email" placeholder="bl00d_n1nja@wh.gov" value="" type="text"></input>
+      </div>
+      <br />
 : }
+
       <input type="submit" id="maximumGo" value="<: $btnmsg :>"></input>
     </form>
+: if ($has_users) {
     <div id="resetpass">
-        <a href="/request_password_reset">Reset Password</a>
+        <a href="/password_reset">Reset Password</a>
     </div>
+: }
 
 </div>
 : include "components/footer.tx";

+ 1 - 0
www/templates/html/password_reset.tx

@@ -0,0 +1 @@
+Your password can be reset by following <a href="<: $uri :>">this link</a>.

+ 1 - 0
www/templates/html/sysbar.tx

@@ -5,6 +5,7 @@
         <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="/password_reset" title="Reset Auth" class="topbar">Reset Password/TOTP</a>
         <a href="/logout"      title="Logout"        class="topbar">🚪</a>
     </span>
 </div>

+ 1 - 0
www/templates/html/totp_reset.tx

@@ -0,0 +1 @@
+Your TOTP 2FA can be disabled by following <a href="<: $uri :>">this</a> link.

+ 3 - 0
www/templates/text/password_reset.tx

@@ -0,0 +1,3 @@
+Your requested password reset can be accomplished by visiting the following link:
+
+<: $uri :>

+ 3 - 0
www/templates/text/totp_reset.tx

@@ -0,0 +1,3 @@
+Your TOTP 2FA can be disabled via following this link:
+
+<: $uri :>