tightened up api def and impl
This commit is contained in:
parent
565edd84ab
commit
0ab5c509d9
78
README.md
78
README.md
|
@ -9,41 +9,63 @@
|
||||||
- Accessed via public APIs.
|
- Accessed via public APIs.
|
||||||
- Open source.
|
- Open source.
|
||||||
- Self-hostable, discovery via domain SRV records.
|
- Self-hostable, discovery via domain SRV records.
|
||||||
- Single giant Perl script because PERL IS AWESOME!
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### Authentication
|
Any dotplan implementation should expose at least the following two endpoints:
|
||||||
|
|
||||||
- `POST /users/{email}` - request new account
|
|
||||||
- request data: `{"password":"whatever"}`
|
|
||||||
- email with validation token will be sent
|
|
||||||
- `GET /users/{email}?token={token}` - validate new account
|
|
||||||
- `GET /token` - retrieve auth token
|
|
||||||
- http basic auth
|
|
||||||
- `?expires={minutes}` sets an explicit expiration, default is 5 minutes from creation
|
|
||||||
- response data: `{"token":"whatever"}`
|
|
||||||
- `DELETE /token` - invalidate current auth token
|
|
||||||
- http basic auth
|
|
||||||
- `GET /users/{email}/pwtoken` - get password change token
|
|
||||||
- email with password change token will be sent
|
|
||||||
- `PUT /users/{email}` - update password
|
|
||||||
- request data: `{"password":"whatever","pwtoken":"whatever"}`
|
|
||||||
- token expires 600 seconds from creation
|
|
||||||
|
|
||||||
### Plans
|
|
||||||
|
|
||||||
- `PUT /plan/{email}` - update a plan
|
|
||||||
- request data: `{"plan":"whatever","signature":"base64 encoded signature","auth":"token"}`
|
|
||||||
- omitting `plan` from the payload will delete the existing plan
|
|
||||||
- `GET /plan/{email}` - retrieve a plan
|
- `GET /plan/{email}` - retrieve a plan
|
||||||
- `text/plain` by default - raw plan content
|
- `text/plain` by default - raw plan content
|
||||||
- `?format=html` or `Accept: text/html` - plan content with html entity encoding for special characters
|
- `?format=html` or `Accept: text/html` - plan content with html entity encoding for special characters
|
||||||
- `?format=json` or `Accept: application/json` - response data: `{"plan":"whatever","signature":"base64 encoded signature"}`
|
- `?format=json` or `Accept: application/json`:
|
||||||
|
- `plan` - raw plan content
|
||||||
|
- `signature` - ascii armored PGP signature if this plan was signed
|
||||||
|
- `timestamp` - when this plan was created
|
||||||
- `404` if no plan found
|
- `404` if no plan found
|
||||||
- `301` redirect if plan is on a different provider
|
- `301` redirect if domain SRV record indicates plan is on a different dotplan provider
|
||||||
- `POST /verify/{email}` - verify PGP signature of a plan
|
- `POST /verify/{email}` - verify PGP signature of a plan
|
||||||
- request data: `{"pubkey":"ascii public key"}`
|
- request json data:
|
||||||
- response data: `{"plan":"whatever","verified":1}` or `{"verified":0}`
|
- `pubkey` - ascii armored public PGP key to verify the signature with
|
||||||
|
- response json data:
|
||||||
|
- `verified` - `1` or `0` depending on whether verification of the plan signature was successful
|
||||||
|
- normal plan details included if `verified=1`
|
||||||
|
- `403` if server-side verification is not supported
|
||||||
- `404` if no plan found
|
- `404` if no plan found
|
||||||
- `308` redirect if plan is on a different provider
|
- `308` redirect if domain SRV record indicates plan is on a different dotplan provider.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The reference dotplan implementation also exposes these endpoints for account management and authentication. Other implementations may differ and offer other authentication mechanisms (OAuth2 for example, or supporting the creation and invalidation of multiple authentication tokens).
|
||||||
|
|
||||||
|
- `POST /users/{email}` - request new account
|
||||||
|
- request json data:
|
||||||
|
- `password` - the password for the new account
|
||||||
|
- an email with a validation link will be sent
|
||||||
|
- `PUT /users/{email}` - validate new account
|
||||||
|
- request json data:
|
||||||
|
- `token` - the validation token from the email
|
||||||
|
- `GET /token` - retrieve auth token
|
||||||
|
- http basic auth
|
||||||
|
- `?expires={minutes}` sets an explicit expiration, default is 5 minutes from creation
|
||||||
|
- response json data:
|
||||||
|
- `token` - the authentication token
|
||||||
|
- `DELETE /token` - invalidate current auth token
|
||||||
|
- http basic auth
|
||||||
|
- `GET /users/{email}/pwchange` - get password change token
|
||||||
|
- an email with a password change token will be sent
|
||||||
|
- token expires 600 seconds from creation
|
||||||
|
- `PUT /users/{email}/pwchange` - update password
|
||||||
|
- request json data:
|
||||||
|
- `password` - the new password
|
||||||
|
- `token` - the password change token from the email
|
||||||
|
|
||||||
|
### Updating a Plan
|
||||||
|
|
||||||
|
The reference dotplan implementation exposes this endpoint to update a plan using a given authentication token. Other implementations may differ and offer other mechanisms to update a plan (by email or text message for example, or integration with other services).
|
||||||
|
|
||||||
|
- `PUT /plan/{email}` - update a plan
|
||||||
|
- request json data:
|
||||||
|
- `plan` - optional new plan content
|
||||||
|
- `signature` - optional ascii encoded PGP signature
|
||||||
|
- `auth` - the authentication token
|
||||||
|
- omitting `plan` from the payload will delete the existing plan
|
||||||
|
|
55
server.pl
55
server.pl
|
@ -101,11 +101,9 @@ if (defined $ENV{'LOCAL_DOMAINS'}) {
|
||||||
eval {
|
eval {
|
||||||
util_log("REQ $req_id $method $path");
|
util_log("REQ $req_id $method $path");
|
||||||
if ($method eq 'GET') {
|
if ($method eq 'GET') {
|
||||||
if ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
if ($path =~ /^\/token$/) {
|
||||||
validate_email($1, $cgi);
|
|
||||||
} elsif ($path =~ /^\/token$/) {
|
|
||||||
get_token($cgi);
|
get_token($cgi);
|
||||||
} elsif ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})\/pwtoken$/) {
|
} elsif ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})\/pwchange$/) {
|
||||||
get_pwtoken($1, $cgi);
|
get_pwtoken($1, $cgi);
|
||||||
} elsif ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
} elsif ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
||||||
get_plan($1, $cgi);
|
get_plan($1, $cgi);
|
||||||
|
@ -122,6 +120,8 @@ if (defined $ENV{'LOCAL_DOMAINS'}) {
|
||||||
}
|
}
|
||||||
} elsif ($method eq 'PUT') {
|
} elsif ($method eq 'PUT') {
|
||||||
if ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
if ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
||||||
|
validate_email($1, $cgi);
|
||||||
|
} elsif ($path =~ /^\/users\/([^\/]{$minimum_email_length,$maximum_email_length})\/pwchange$/) {
|
||||||
update_password($1, $cgi);
|
update_password($1, $cgi);
|
||||||
} elsif ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
} elsif ($path =~ /^\/plan\/([^\/]{$minimum_email_length,$maximum_email_length})$/) {
|
||||||
update_plan($1, $cgi);
|
update_plan($1, $cgi);
|
||||||
|
@ -181,24 +181,6 @@ EOF
|
||||||
print_response($cgi, $code, encode_json($data));
|
print_response($cgi, $code, encode_json($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
sub print_html_response {
|
|
||||||
# TODO: external template
|
|
||||||
my ($cgi, $code, $content) = @_;
|
|
||||||
print_response($cgi, $code, <<EOF
|
|
||||||
<!doctype html>
|
|
||||||
<html lang='en'>
|
|
||||||
<head>
|
|
||||||
<title>Dotplan Online</title>
|
|
||||||
<meta charset='utf-8'>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<p>$content</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
EOF
|
|
||||||
, 'text/html');
|
|
||||||
}
|
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# API Implementation
|
# API Implementation
|
||||||
####################
|
####################
|
||||||
|
@ -236,20 +218,20 @@ EOF
|
||||||
##### GET /users/{email}?token={token}
|
##### GET /users/{email}?token={token}
|
||||||
sub validate_email {
|
sub validate_email {
|
||||||
my ($email, $cgi) = @_;
|
my ($email, $cgi) = @_;
|
||||||
my $token = $cgi->param('token');
|
my $token = util_json_body($cgi)->{'token'};
|
||||||
if (!defined $token) {
|
if (!defined $token) {
|
||||||
print_html_response($cgi, 400, 'No token found in request.');
|
print_json_response($cgi, 400, {error => 'Missing token.'});
|
||||||
} 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($cgi, 404, 'User not found.');
|
print_response($cgi, 404, $not_found);
|
||||||
} elsif ($user->{'pw_token'} ne $token) {
|
} elsif ($user->{'pw_token'} ne $token) {
|
||||||
print_html_response($cgi, 400, 'Bad or expired token.');
|
print_response($cgi, 401, $not_authorized);
|
||||||
} 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);
|
||||||
die $sth->errstr if $sth->err;
|
die $sth->errstr if $sth->err;
|
||||||
print_html_response($cgi, 200, 'Your email address has been verified.');
|
print_json_response($cgi, 200, {success => 1});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -296,16 +278,16 @@ EOF
|
||||||
my ($email, $cgi) = @_;
|
my ($email, $cgi) = @_;
|
||||||
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($cgi, 404, 'User not found.');
|
print_response($cgi, 404, $not_found);
|
||||||
} elsif (defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) {
|
} elsif (defined $user->{'pw_token_expires'} && $user->{'pw_token_expires'} >= time) {
|
||||||
print_html_response($cgi, 429, "Please wait up to $pw_token_expiration_minutes minutes and try again.");
|
print_json_response($cgi, 429, {error => "Wait $pw_token_expiration_minutes between this type of request."});
|
||||||
} else {
|
} else {
|
||||||
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;
|
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_json_response($cgi, 200, {success => 1});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -318,7 +300,7 @@ EOF
|
||||||
} else {
|
} else {
|
||||||
my $body = util_json_body($cgi);
|
my $body = util_json_body($cgi);
|
||||||
my $password = $body->{'password'};
|
my $password = $body->{'password'};
|
||||||
my $pwtoken = $body->{'pwtoken'};
|
my $pwtoken = $body->{'token'};
|
||||||
if (!defined $pwtoken || !defined $user->{'pw_token'} || !defined $user->{'pw_token_expires'} || $pwtoken ne $user->{'pw_token'} || $user->{'pw_token_expires'} < time) {
|
if (!defined $pwtoken || !defined $user->{'pw_token'} || !defined $user->{'pw_token_expires'} || $pwtoken ne $user->{'pw_token'} || $user->{'pw_token_expires'} < time) {
|
||||||
print_json_response($cgi, 400, {error => 'Bad or expired token.'});
|
print_json_response($cgi, 400, {error => 'Bad or expired token.'});
|
||||||
} elsif (!defined $password || length($password) < $minimum_password_length) {
|
} elsif (!defined $password || length($password) < $minimum_password_length) {
|
||||||
|
@ -383,7 +365,7 @@ EOF
|
||||||
if ($format eq 'application/json') {
|
if ($format eq 'application/json') {
|
||||||
print_response($cgi, 404, $not_found);
|
print_response($cgi, 404, $not_found);
|
||||||
} elsif ($format eq 'text/html') {
|
} elsif ($format eq 'text/html') {
|
||||||
print_html_response($cgi, 404, 'No plan found.');
|
print_response($cgi, 404, '', 'text/html');
|
||||||
} else {
|
} else {
|
||||||
print_response($cgi, 404, '', 'text/plain');
|
print_response($cgi, 404, '', 'text/plain');
|
||||||
}
|
}
|
||||||
|
@ -414,10 +396,8 @@ EOF
|
||||||
(IPC::Run::run ['gpg2', '--dearmor'], '<', $keyfile, '>', "$keyfile.gpg", '2>>', '/dev/null') &&
|
(IPC::Run::run ['gpg2', '--dearmor'], '<', $keyfile, '>', "$keyfile.gpg", '2>>', '/dev/null') &&
|
||||||
(IPC::Run::run ['gpg2', '--no-default-keyring', '--keyring', "$keyfile.gpg", '--verify', "$basename.asc", "$basename.plan"], '>', '/dev/null', '2>>', '/dev/null')
|
(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, {
|
$plan->{'verified'} = 1;
|
||||||
plan => $plan->{'plan'},
|
print_json_response($cgi, 200, $plan);
|
||||||
verified => 1
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
print_json_response($cgi, 200, {verified => 0});
|
print_json_response($cgi, 200, {verified => 0});
|
||||||
}
|
}
|
||||||
|
@ -586,6 +566,9 @@ EOF
|
||||||
my $details = {};
|
my $details = {};
|
||||||
open(my $plan_file, '<', "$basename.plan");
|
open(my $plan_file, '<', "$basename.plan");
|
||||||
flock($plan_file, LOCK_SH);
|
flock($plan_file, LOCK_SH);
|
||||||
|
my $mtime = (stat($plan_file))[9];
|
||||||
|
my $timestamp = strftime("%a, %d %b %Y %H:%M:%S %z", localtime($mtime));
|
||||||
|
$details->{'timestamp'} = $timestamp;
|
||||||
local $/;
|
local $/;
|
||||||
$details->{'plan'} = <$plan_file>;
|
$details->{'plan'} = <$plan_file>;
|
||||||
close($plan_file);
|
close($plan_file);
|
||||||
|
|
24
test/run.sh
24
test/run.sh
|
@ -76,10 +76,11 @@ assert_equal_jq() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_notnull_jq() {
|
assert_notequal_jq() {
|
||||||
selector=$1;shift
|
selector=$1;shift
|
||||||
|
expected=$1;shift
|
||||||
actual=$(echo "$TEST_CONTENT" | jq -r "$selector")
|
actual=$(echo "$TEST_CONTENT" | jq -r "$selector")
|
||||||
if [ "$actual" == "null" ]; then
|
if [ "$actual" == "$expected" ]; then
|
||||||
printf "${RED}✗ CHECK${NC} ${BOLD}$selector${NC} is null\n"
|
printf "${RED}✗ CHECK${NC} ${BOLD}$selector${NC} is null\n"
|
||||||
((++FAILED))
|
((++FAILED))
|
||||||
return 1
|
return 1
|
||||||
|
@ -139,11 +140,14 @@ curl_test 'Rate limit registrations' 429 'application/json' -XPOST -d "$REQ_DATA
|
||||||
|
|
||||||
pw_token=$(echo "select pw_token from users where email='$TEST_USER'" | sqlite3 "$BASEDIR/data/test.db")
|
pw_token=$(echo "select pw_token from users where email='$TEST_USER'" | sqlite3 "$BASEDIR/data/test.db")
|
||||||
|
|
||||||
curl_test 'Reject bad verification token' 400 'text/html' localhost:$PORT/users/$TEST_USER?token=thisiswrong
|
curl_test 'Reject bad verification token' 401 'application/json' -XPUT -d "{\"token\":\"thisiswrong\"}" localhost:$PORT/users/$TEST_USER \
|
||||||
|
&& assert_notequal_jq '.success' 1
|
||||||
|
|
||||||
curl_test 'Reject bad verification email' 404 'text/html' localhost:$PORT/users/testuser@exmapl3.com?token=$pw_token
|
curl_test 'Reject bad verification email' 404 'application/json' -XPUT -d "{\"token\":\"$pw_token\"}" localhost:$PORT/users/testuser@exmapl3.com \
|
||||||
|
&& assert_notequal_jq '.success' 1
|
||||||
|
|
||||||
curl_test 'Verify email address' 200 'text/html' localhost:$PORT/users/$TEST_USER?token=$pw_token
|
curl_test 'Verify email address' 200 'application/json' -XPUT -d "{\"token\":\"$pw_token\"}" localhost:$PORT/users/$TEST_USER \
|
||||||
|
&& assert_equal_jq '.success' 1
|
||||||
|
|
||||||
curl_test 'Reject incorrect email' 401 'application/json' -u testuser@exampl3.com:test1234 localhost:$PORT/token
|
curl_test 'Reject incorrect email' 401 'application/json' -u testuser@exampl3.com:test1234 localhost:$PORT/token
|
||||||
|
|
||||||
|
@ -206,13 +210,15 @@ token=$(echo "$TEST_CONTENT" | jq -r '.token')
|
||||||
|
|
||||||
curl_test 'Accept new authentication token' 200 'application/json' -XPUT -d "{\"plan\":\"this should not fail\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER
|
curl_test 'Accept new authentication token' 200 'application/json' -XPUT -d "{\"plan\":\"this should not fail\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER
|
||||||
|
|
||||||
curl_test 'Generate password reset token' 200 'text/html' localhost:$PORT/users/$TEST_USER/pwtoken
|
curl_test 'Generate password reset token' 200 'application/json' localhost:$PORT/users/$TEST_USER/pwchange \
|
||||||
|
&& assert_equal_jq '.success' 1
|
||||||
|
|
||||||
pw_token=$(echo "select pw_token from users where email='$TEST_USER'" | sqlite3 "$BASEDIR/data/test.db")
|
pw_token=$(echo "select pw_token from users where email='$TEST_USER'" | sqlite3 "$BASEDIR/data/test.db")
|
||||||
|
|
||||||
curl_test 'Reject invalid password reset token' 400 'application/json' -XPUT -d "{\"password\":\"newpassword\",\"pwtoken\":\"thisiswrong\"}" localhost:$PORT/users/$TEST_USER
|
curl_test 'Reject invalid password reset token' 400 'application/json' -XPUT -d "{\"password\":\"newpassword\",\"token\":\"thisiswrong\"}" localhost:$PORT/users/$TEST_USER/pwchange
|
||||||
|
|
||||||
curl_test 'Reset password' 200 'application/json' -XPUT -d "{\"password\":\"newpassword\",\"pwtoken\":\"$pw_token\"}" localhost:$PORT/users/$TEST_USER
|
curl_test 'Reset password' 200 'application/json' -XPUT -d "{\"password\":\"newpassword\",\"token\":\"$pw_token\"}" localhost:$PORT/users/$TEST_USER/pwchange \
|
||||||
|
&& assert_equal_jq '.success' 1
|
||||||
|
|
||||||
curl_test 'Reject authentication token after password reset' 401 'application/json' -XPUT -d "{\"plan\":\"this should fail\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER
|
curl_test 'Reject authentication token after password reset' 401 'application/json' -XPUT -d "{\"plan\":\"this should fail\",\"auth\":\"$token\"}" localhost:$PORT/plan/$TEST_USER
|
||||||
|
|
||||||
|
@ -229,7 +235,7 @@ curl_test 'Create signed plan' 200 'application/json' -XPUT -d "$put_data" local
|
||||||
curl_test 'Get signed plan' 200 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/$TEST_USER \
|
curl_test 'Get signed plan' 200 'application/json' -H 'Accept: application/json' localhost:$PORT/plan/$TEST_USER \
|
||||||
&& assert_equal_jq '.plan' 'this is a plan
|
&& assert_equal_jq '.plan' 'this is a plan
|
||||||
that is signed' \
|
that is signed' \
|
||||||
&& assert_notnull_jq '.signature'
|
&& assert_notequal_jq '.signature' 'null'
|
||||||
|
|
||||||
post_data=$(<"$BASEDIR/signed-verify-bad.json")
|
post_data=$(<"$BASEDIR/signed-verify-bad.json")
|
||||||
curl_test 'Fail to verify with bad pubkey' 200 'application/json' -XPOST -d "$post_data" localhost:$PORT/verify/$TEST_USER \
|
curl_test 'Fail to verify with bad pubkey' 200 'application/json' -XPOST -d "$post_data" localhost:$PORT/verify/$TEST_USER \
|
||||||
|
|
Reference in New Issue