finished pubkey validation service

This commit is contained in:
Rudis Muiznieks 2020-07-18 15:23:29 -05:00
parent 051716c095
commit fc16e6d621
2 changed files with 59 additions and 40 deletions

View File

@ -43,7 +43,7 @@
- `404` if no plan found - `404` if no plan found
- `301` redirect if plan is on a different provider - `301` redirect if plan is on a different provider
- `POST /verify/{email}` - verify PGP signature of a plan - `POST /verify/{email}` - verify PGP signature of a plan
- request data: `{"pgpkey":"ascii public key"}` - request data: `{"pubkey":"ascii public key"}`
- response data: `{"plan":"whatever","verified":1}` or `{"verified":0}` - response data: `{"plan":"whatever","verified":1}` or `{"verified":0}`
- `404` if no plan found - `404` if no plan found
- `308` redirect if plan is on a different provider - `308` redirect if plan is on a different provider

View File

@ -10,10 +10,10 @@ use open qw(:std :utf8);
###################### ######################
my $server_port = $ENV{'PORT'} || 4227; my $server_port = $ENV{'PORT'} || 4227;
my $pid_file = $ENV{'PID_FILE'} || './dotplan.pid'; my $pid_file = $ENV{'PID_FILE'} || './data/dotplan.pid';
my $log_file = $ENV{'LOG_FILE'} || './dotplan.log'; my $log_file = $ENV{'LOG_FILE'} || './data/dotplan.log';
my $database = $ENV{'DATABASE'} || './users.db'; my $database = $ENV{'DATABASE'} || './data/users.db';
my $plan_dir = $ENV{'PLAN_DIR'} || './plans'; my $plan_dir = $ENV{'PLAN_DIR'} || './data/plans';
my $sendmail = $ENV{'SENDMAIL'} || '/usr/bin/sendmail'; my $sendmail = $ENV{'SENDMAIL'} || '/usr/bin/sendmail';
my $pw_token_expiration_minutes = $ENV{'PW_TOKEN_EXPIRATION_MINUTES'} || 10; my $pw_token_expiration_minutes = $ENV{'PW_TOKEN_EXPIRATION_MINUTES'} || 10;
@ -38,6 +38,7 @@ if (defined $ENV{'LOCAL_DOMAINS'}) {
{ {
package DotplanApi; package DotplanApi;
use base qw(HTTP::Server::Simple::CGI); use base qw(HTTP::Server::Simple::CGI);
sub net_server { 'Net::Server::Fork' }
# Caching DNS resolver # Caching DNS resolver
{ {
@ -49,6 +50,7 @@ if (defined $ENV{'LOCAL_DOMAINS'}) {
} }
} }
use IPC::Run;
use DBI; use DBI;
use File::Temp qw(tempfile); use File::Temp qw(tempfile);
use Fcntl qw(:flock); use Fcntl qw(:flock);
@ -83,8 +85,6 @@ if (defined $ENV{'LOCAL_DOMAINS'}) {
500 => 'Internal Server Error' 500 => 'Internal Server Error'
}; };
sub net_server { 'Net::Server::Fork' }
################# #################
# Request Routing # Request Routing
################# #################
@ -212,7 +212,7 @@ EOF
my $user = util_get_user($email); my $user = util_get_user($email);
if (defined $user && $user->{'verified'}) { if (defined $user && $user->{'verified'}) {
print_json_response($cgi, 400, {error => 'User already exists.'}); print_json_response($cgi, 400, {error => 'User already exists.'});
} elsif (defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) { } elsif (defined $user && defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) {
print_json_response($cgi, 429, {error => "Please wait up to $pw_token_expiration_minutes minutes and try again."}); print_json_response($cgi, 429, {error => "Please wait up to $pw_token_expiration_minutes minutes and try again."});
} else { } else {
my $password = util_json_body($cgi)->{'password'}; my $password = util_json_body($cgi)->{'password'};
@ -222,9 +222,10 @@ EOF
my $query = (defined $user) my $query = (defined $user)
? "UPDATE users SET password=?, pw_token=?, pw_token_expires=datetime('now', '+$pw_token_expiration_minutes minutes') WHERE email=?" ? "UPDATE users SET password=?, pw_token=?, pw_token_expires=datetime('now', '+$pw_token_expiration_minutes minutes') WHERE email=?"
: "INSERT INTO users (password, pw_token, pw_token_expires, email) values (?, ?, datetime('now', '+$pw_token_expiration_minutes minutes'), ?)"; : "INSERT INTO users (password, pw_token, pw_token_expires, email) values (?, ?, datetime('now', '+$pw_token_expiration_minutes minutes'), ?)";
my $sth = util_get_dbh()->prepare($query);
my $crypted = util_bcrypt($password); my $crypted = util_bcrypt($password);
my $sth = util_get_dbh()->prepare($query);
$sth->execute($crypted, util_token(), $email); $sth->execute($crypted, util_token(), $email);
die $sth->errstr if $sth->err;
# TODO: send email # TODO: send email
print_json_response($cgi, 200, {email => $email}); print_json_response($cgi, 200, {email => $email});
} }
@ -237,17 +238,18 @@ EOF
my ($email, $cgi) = @_; my ($email, $cgi) = @_;
my $token = $cgi->param('token'); my $token = $cgi->param('token');
if (!defined $token) { if (!defined $token) {
print_html_response(400, 'No token found in request.'); print_html_response($cgi, 400, 'No token found in request.');
} else { } else {
my $user = util_get_user($email); my $user = util_get_user($email);
if (!defined $user || $user->{'verified'}) { if (!defined $user || $user->{'verified'}) {
print_html_response(404, 'User not found.'); print_html_response($cgi, 404, 'User not found.');
} elsif ($user->{'pw_token'} ne $token) { } elsif ($user->{'pw_token'} ne $token) {
print_html_response(400, 'Bad or expired token.'); print_html_response($cgi, 400, 'Bad or expired token.');
} else { } else {
my $sth = util_get_dbh()->prepare('UPDATE users SET verified=1, pw_token=null, pw_token_expires=null WHERE email=?'); my $sth = util_get_dbh()->prepare('UPDATE users SET verified=1, pw_token=null, pw_token_expires=null WHERE email=?');
$sth->execute($email); $sth->execute($email);
print_html_response(200, 'Your email address has been verified.'); die $sth->errstr if $sth->err;
print_html_response($cgi, 200, 'Your email address has been verified.');
} }
} }
} }
@ -270,6 +272,7 @@ EOF
} }
} }
$sth->execute($token, "+$minutes minutes", $user->{'email'}); $sth->execute($token, "+$minutes minutes", $user->{'email'});
die $sth->errstr if $sth->err;
print_json_response($cgi, 200, {token => $token}); print_json_response($cgi, 200, {token => $token});
} }
} }
@ -283,6 +286,7 @@ EOF
} else { } else {
my $sth = util_get_dbh()->prepare('UPDATE users SET token=null, token_expires=null WHERE email=?'); my $sth = util_get_dbh()->prepare('UPDATE users SET token=null, token_expires=null WHERE email=?');
$sth->execute($user->{'email'}); $sth->execute($user->{'email'});
die $sth->errstr if $sth->err;
print_json_response($cgi, 200, {success => 1}); print_json_response($cgi, 200, {success => 1});
} }
} }
@ -299,6 +303,7 @@ EOF
my $token = util_token(); my $token = util_token();
my $sth = util_get_dbh()->prepare("UPDATE users SET pw_token=?, pw_token_expires=datetime('now', '+10 minutes') WHERE email=?"); my $sth = util_get_dbh()->prepare("UPDATE users SET pw_token=?, pw_token_expires=datetime('now', '+10 minutes') WHERE email=?");
$sth->execute($token, $email); $sth->execute($token, $email);
die $sth->errstr if $sth->err;
# TODO: send email # TODO: send email
print_html_response($cgi, 200, 'Check your email and follow the instructions to change your password.'); print_html_response($cgi, 200, 'Check your email and follow the instructions to change your password.');
} }
@ -320,8 +325,9 @@ EOF
print_json_response($cgi, 400, {error => "Password must be at least $minimum_password_length characters long."}); print_json_response($cgi, 400, {error => "Password must be at least $minimum_password_length characters long."});
} else { } else {
my $crypted = util_bcrypt($password); my $crypted = util_bcrypt($password);
my $sth = util_get_dbh()->prepare('UPDATE users SET password=?, pw_token=null, pw_token_expires=null WHERE email=?'); my $sth = util_get_dbh()->prepare('UPDATE users SET password=?, pw_token=null, pw_token_expires=null, token=null, token_expires=null WHERE email=?');
$sth->execute($crypted, $email); $sth->execute($crypted, $email);
die $sth->errstr if $sth->err;
print_json_response($cgi, 200, {success => 1}); print_json_response($cgi, 200, {success => 1});
} }
} }
@ -355,6 +361,7 @@ EOF
sub get_plan { sub get_plan {
my ($email, $cgi) = @_; my ($email, $cgi) = @_;
my $format = util_get_response_format($cgi);
my $plan = util_get_plan($email); my $plan = util_get_plan($email);
if (defined $plan && defined $plan->{'redirect'}) { if (defined $plan && defined $plan->{'redirect'}) {
@ -362,25 +369,24 @@ EOF
print_response($cgi, 301, encode_json({location => $plan->{'redirect'}}), 'application/json', $plan->{'redirect'}); print_response($cgi, 301, encode_json({location => $plan->{'redirect'}}), 'application/json', $plan->{'redirect'});
} elsif (defined $plan) { } elsif (defined $plan) {
# found local plan, render response # found local plan, render response
my $accept = $cgi->http('Accept');
my $format = lc($cgi->param('format') || $cgi->http('Accept'));
my $body; my $body;
if ($format eq 'json' || $format eq 'application/json') { if ($format eq 'application/json') {
$format = 'application/json';
$body = encode_json($plan); $body = encode_json($plan);
} elsif ($format eq 'html' || $format eq 'text/html') { } elsif ($format eq 'text/html') {
$format = 'text/html';
$body = encode_entities($plan->{'plan'}); $body = encode_entities($plan->{'plan'});
$body =~ s/\n/<br>\n/g; $body =~ s/\n/<br>\n/g;
} else { } else {
$format = 'text/plain';
$body = $plan->{'plan'}; $body = $plan->{'plan'};
} }
print_response($cgi, 200, $body, $format); print_response($cgi, 200, $body, $format);
} else { } else {
print_response($cgi, 404, $not_found); if ($format eq 'application/json') {
print_response($cgi, 404, $not_found);
} elsif ($format eq 'text/html') {
print_html_response($cgi, 404, 'No plan found.');
} else {
print_response($cgi, 404, '', 'text/plain');
}
} }
} }
@ -394,7 +400,7 @@ EOF
# found external plan service, redirect request # found external plan service, redirect request
print_response($cgi, 308, encode_json({location => $plan->{'redirect'}}), 'application/json', $plan->{'redirect'}); print_response($cgi, 308, encode_json({location => $plan->{'redirect'}}), 'application/json', $plan->{'redirect'});
} elsif (defined $plan) { } elsif (defined $plan) {
my $pubkey = util_json_body($cgi)->{'pgpkey'}; my $pubkey = util_json_body($cgi)->{'pubkey'};
if (!defined $pubkey || !defined $plan->{'signature'}) { if (!defined $pubkey || !defined $plan->{'signature'}) {
print_json_response($cgi, 200, {verified => 0}); print_json_response($cgi, 200, {verified => 0});
} elsif (length($pubkey) > $maximum_pubkey_length) { } elsif (length($pubkey) > $maximum_pubkey_length) {
@ -403,17 +409,17 @@ EOF
my ($keyfh, $keyfile) = tempfile('tmpXXXXXX', TMPDIR => 1); my ($keyfh, $keyfile) = tempfile('tmpXXXXXX', TMPDIR => 1);
print $keyfh $pubkey; print $keyfh $pubkey;
close($keyfh); close($keyfh);
util_log("saved key file to $keyfile");
my $basename = "$plan_dir/" . shell_quote($email); my $basename = "$plan_dir/" . shell_quote($email);
my $convert = system("gpg2 --dearmor $keyfile > $keyfile.gpg"); if(
my $valid = system("gpg2 --no-default-keyring --keyring $keyfile.gpg --verify $basename.sig $basename.plan"); (IPC::Run::run ['gpg2', '--dearmor'], '<', $keyfile, '>', "$keyfile.gpg", '2>>', '/dev/null') &&
if ($convert != 0 || $valid != 0) { (IPC::Run::run ['gpg2', '--no-default-keyring', '--keyring', "$keyfile.gpg", '--verify', "$basename.asc", "$basename.plan"], '>', '/dev/null', '2>>', '/dev/null')
print_json_response($cgi, 200, {verified => 0}); ) {
} else {
print_json_response($cgi, 200, { print_json_response($cgi, 200, {
plan => $plan->{'plan'}, plan => $plan->{'plan'},
verified => 1 verified => 1
}); });
} else {
print_json_response($cgi, 200, {verified => 0});
} }
} }
} else { } else {
@ -446,6 +452,18 @@ EOF
print $_log "$timestamp $msg\n"; print $_log "$timestamp $msg\n";
} }
sub util_get_response_format {
my $cgi = shift;
my $accept = $cgi->http('Accept');
my $format = lc($cgi->param('format') || $cgi->http('Accept'));
if ($format eq 'json' || $format eq 'application/json') {
return 'application/json';
} elsif ($format eq 'html' || $format eq 'text/html') {
return 'text/html';
}
return 'text/plain';
}
# encrypt a password with a provided or random salt # encrypt a password with a provided or random salt
sub util_bcrypt { sub util_bcrypt {
my ($password, $salt) = @_; my ($password, $salt) = @_;
@ -525,7 +543,9 @@ EOF
my $email = shift; my $email = shift;
my $sth = util_get_dbh()->prepare("SELECT email, password, token, strftime('%s', token_expires) AS token_expires, pw_token, strftime('%s', pw_token_expires) AS pw_token_expires, verified, strftime('%s', created) AS created, strftime('%s', updated) AS updated FROM users WHERE email=?"); my $sth = util_get_dbh()->prepare("SELECT email, password, token, strftime('%s', token_expires) AS token_expires, pw_token, strftime('%s', pw_token_expires) AS pw_token_expires, verified, strftime('%s', created) AS created, strftime('%s', updated) AS updated FROM users WHERE email=?");
$sth->execute($email); $sth->execute($email);
return $sth->fetchrow_hashref; die $sth->errstr if $sth->err;
my $user = $sth->fetchrow_hashref;
return (keys %$user > 0) ? $user : undef;
} }
# save a plan by email # save a plan by email
@ -544,13 +564,12 @@ EOF
} }
if (defined $plan && defined $signature) { if (defined $plan && defined $signature) {
open(my $sig_file, '>', "$basename.sig"); open(my $sig_file, '>', "$basename.asc");
flock($sig_file, LOCK_EX); flock($sig_file, LOCK_EX);
binmode $sig_file; print $sig_file $signature;
print $sig_file decode_base64($signature);
close($sig_file); close($sig_file);
} elsif (-f "$basename.sig") { } elsif (-f "$basename.asc") {
unlink "$basename.sig"; unlink "$basename.asc";
} }
# invalidate cache # invalidate cache
@ -571,8 +590,8 @@ EOF
$details->{'plan'} = <$plan_file>; $details->{'plan'} = <$plan_file>;
close($plan_file); close($plan_file);
if (-f "$basename.sig") { if (-f "$basename.asc") {
open(my $sig_file, '<', "$basename.sig"); open(my $sig_file, '<', "$basename.asc");
flock($sig_file, LOCK_SH); flock($sig_file, LOCK_SH);
local $/; local $/;
$details->{'signature'} = <$sig_file>; $details->{'signature'} = <$sig_file>;
@ -625,7 +644,7 @@ EOF
} }
# only supports one optional argument -d to daemonize # only supports one optional argument -d to daemonize
my $daemonize = $ARGV[0] == '-d' if @ARGV == 1; my $daemonize = $ARGV[0] eq '-d' if @ARGV == 1;
# start server and fork process as current user # start server and fork process as current user
my ($user, $passwd, $uid, $gid) = getpwuid $<; my ($user, $passwd, $uid, $gid) = getpwuid $<;