Implement changes for API authentication and Swagger UI (#541)

* initial token-based-api login handler

* add token schema and validation

* initial import of pyro3d api code

* basic Swagger spec support

* Merge in working copy of API/Device.pm

* Fix some error handling for API/Device.pm

* Break out utility functions into separate file, to allow other api portions to use

* Add NodeIP support.

* Add nodeip plugin to config

* remove double define of "plugin:" (#448)

disclaimer: i did not test this is any way, came across it when looking for something else.

* only AuthZ header for api use, and alway regen key on login

* use RFC7235

* workaround for Swagger plugin weird response body

* do not autodiscover swagger routes

* code formatting only

* move api util to utils area

* initial full swagger spec for nodeip search

* add api user role and fix api auth failure response

* update version of swagger-ui to 3.20.3

* add more openapi defs

* fixes to SQL and api spec

* clean up subs

* improvements to login/logout for API

* make api logout work

* add openapi tags to group operations

* allow api params to be generated from DBIC schema spec

* remove API calls for nodes and devices

* remove some poor assumptions about api calls

* tidy up

* remove DDP

* make login and logout similar

* example of api call being handled by ajax call

* make the branch authonly
This commit is contained in:
Oliver Gorwits
2019-03-17 20:27:19 +00:00
committed by GitHub
parent df1a150f0c
commit 46d8c52a08
25 changed files with 465 additions and 20 deletions
+1
View File
@@ -36,6 +36,7 @@ Module::Build->new(
'Dancer::Plugin::DBIC' => '0.2001', 'Dancer::Plugin::DBIC' => '0.2001',
'Dancer::Plugin::Auth::Extensible' => '0.30', 'Dancer::Plugin::Auth::Extensible' => '0.30',
'Dancer::Plugin::Passphrase' => '2.0.1', 'Dancer::Plugin::Passphrase' => '2.0.1',
'Dancer::Plugin::Swagger' => '0',
'Dancer::Session::Cookie' => '0.27', 'Dancer::Session::Cookie' => '0.27',
'Expect' => '0', 'Expect' => '0',
'File::ShareDir' => '1.03', 'File::ShareDir' => '1.03',
+5
View File
@@ -6,6 +6,7 @@ use Dancer ':script';
use Path::Class 'dir'; use Path::Class 'dir';
use Net::Domain 'hostdomain'; use Net::Domain 'hostdomain';
use File::ShareDir 'dist_dir';
BEGIN { BEGIN {
if (setting('include_paths') and ref [] eq ref setting('include_paths')) { if (setting('include_paths') and ref [] eq ref setting('include_paths')) {
@@ -204,4 +205,8 @@ if (setting('reports') and ref {} eq ref setting('reports')) {
# add system_reports onto reports # add system_reports onto reports
config->{'reports'} = [ @{setting('system_reports')}, @{setting('reports')} ]; config->{'reports'} = [ @{setting('system_reports')}, @{setting('reports')} ];
# set swagger ui location
config->{plugins}->{Swagger}->{ui_dir}
= dir(dist_dir('App-Netdisco'), 'swagger-ui')->absolute;
true; true;
+1 -1
View File
@@ -11,7 +11,7 @@ __PACKAGE__->load_namespaces(
); );
our # try to hide from kwalitee our # try to hide from kwalitee
$VERSION = 54; # schema version used for upgrades, keep as integer $VERSION = 55; # schema version used for upgrades, keep as integer
use Path::Class; use Path::Class;
use File::ShareDir 'dist_dir'; use File::ShareDir 'dist_dir';
+4
View File
@@ -14,6 +14,10 @@ __PACKAGE__->add_columns(
{ data_type => "varchar", is_nullable => 0, size => 50 }, { data_type => "varchar", is_nullable => 0, size => 50 },
"password", "password",
{ data_type => "text", is_nullable => 1 }, { data_type => "text", is_nullable => 1 },
"token",
{ data_type => "text", is_nullable => 1 },
"token_from",
{ data_type => "integer", is_nullable => 1 },
"creation", "creation",
{ {
data_type => "timestamp", data_type => "timestamp",
@@ -19,6 +19,9 @@ __PACKAGE__->result_source_instance->view_definition(<<ENDSQL
UNION UNION
SELECT username, 'ldap' AS role FROM users SELECT username, 'ldap' AS role FROM users
WHERE ldap WHERE ldap
UNION
SELECT username, 'api' AS role FROM users
WHERE token IS NOT NULL AND token_from IS NOT NULL
ENDSQL ENDSQL
); );
+33
View File
@@ -5,6 +5,7 @@ use Dancer::Plugin::Ajax;
use Dancer::Plugin::DBIC; use Dancer::Plugin::DBIC;
use Dancer::Plugin::Auth::Extensible; use Dancer::Plugin::Auth::Extensible;
use Dancer::Plugin::Swagger;
use URI (); use URI ();
use Socket6 (); # to ensure dependency is met use Socket6 (); # to ensure dependency is met
@@ -75,6 +76,28 @@ eval {
}; };
Dancer::Session::Cookie::init(session); Dancer::Session::Cookie::init(session);
# setup for swagger API
my $swagger = Dancer::Plugin::Swagger->instance->doc;
$swagger->{schemes} = ['http','https'];
$swagger->{consumes} = 'application/json';
$swagger->{produces} = 'application/json';
$swagger->{tags} = [
{name => 'Global'},
{name => 'Devices',
description => 'Operations relating to Devices (switches, routers, etc)'},
{name => 'Nodes',
description => 'Operations relating to Nodes (end-stations such as printers)'},
{name => 'NodeIPs',
description => 'Operations relating to MAC-IP mappings (IPv4 ARP and IPv6 Neighbors)'},
];
$swagger->{securityDefinitions} = {
APIKeyHeader =>
{ type => 'apiKey', name => 'Authorization', in => 'header' },
BasicAuth =>
{ type => 'basic' },
};
$swagger->{security} = [ { APIKeyHeader => [] } ];
# workaround for https://github.com/PerlDancer/Dancer/issues/935 # workaround for https://github.com/PerlDancer/Dancer/issues/935
hook after_error_render => sub { setting('layout' => 'main') }; hook after_error_render => sub { setting('layout' => 'main') };
@@ -182,6 +205,16 @@ hook 'before_template' => sub {
$Template::Stash::PRIVATE = undef; $Template::Stash::PRIVATE = undef;
}; };
# workaround for Swagger plugin weird response body
hook 'after' => sub {
my $r = shift; # a Dancer::Response
if (request->path eq '/swagger.json') {
$r->content( to_json( $r->content ) );
header('Content-Type' => 'application/json');
}
};
# remove empty lines from CSV response # remove empty lines from CSV response
# this makes writing templates much more straightforward! # this makes writing templates much more straightforward!
hook 'after' => sub { hook 'after' => sub {
@@ -53,6 +53,28 @@ sub get_user_details {
return $user; return $user;
} }
sub validate_api_token {
my ($self, $token) = @_;
return unless defined $token;
my $settings = $self->realm_settings;
my $database = schema($settings->{schema_name})
or die "No database connection";
my $users_table = $settings->{users_resultset} || 'User';
my $token_column = $settings->{users_token_column} || 'token';
$token =~ s/^Apikey //i; # should be there but swagger-ui doesn't add it
my $user = try {
$database->resultset($users_table)->find({ $token_column => $token });
};
return $user->username
if $user and $user->in_storage and $user->token_from
and $user->token_from > (time - setting('api_token_lifetime'));
return undef;
}
sub get_user_roles { sub get_user_roles {
my ($self, $username) = @_; my ($self, $username) = @_;
return unless defined $username; return unless defined $username;
+96 -13
View File
@@ -3,6 +3,15 @@ package App::Netdisco::Web::AuthN;
use Dancer ':syntax'; use Dancer ':syntax';
use Dancer::Plugin::DBIC; use Dancer::Plugin::DBIC;
use Dancer::Plugin::Auth::Extensible; use Dancer::Plugin::Auth::Extensible;
use Dancer::Plugin::Swagger;
use MIME::Base64;
sub request_is_api {
return (setting('api_token_lifetime')
and request->header('Authorization')
and request->accept =~ m/(?:json|javascript)/);
}
hook 'before' => sub { hook 'before' => sub {
params->{return_url} ||= ((request->path ne uri_for('/')->path) params->{return_url} ||= ((request->path ne uri_for('/')->path)
@@ -11,7 +20,12 @@ hook 'before' => sub {
# from the internals of Dancer::Plugin::Auth::Extensible # from the internals of Dancer::Plugin::Auth::Extensible
my $provider = Dancer::Plugin::Auth::Extensible::auth_provider('users'); my $provider = Dancer::Plugin::Auth::Extensible::auth_provider('users');
if (! session('logged_in_user') && request->path ne uri_for('/login')->path) { if (! session('logged_in_user')
and request->path ne uri_for('/login')->path
and request->path ne uri_for('/logout')->path
and request->path ne uri_for('/swagger.json')->path
and index(request->path, uri_for('/swagger-ui')->path) != 0) {
if (setting('trust_x_remote_user') if (setting('trust_x_remote_user')
and scalar request->header('X-REMOTE_USER') and scalar request->header('X-REMOTE_USER')
and length scalar request->header('X-REMOTE_USER')) { and length scalar request->header('X-REMOTE_USER')) {
@@ -38,6 +52,16 @@ hook 'before' => sub {
session(logged_in_user => 'guest'); session(logged_in_user => 'guest');
session(logged_in_user_realm => 'users'); session(logged_in_user_realm => 'users');
} }
elsif (request_is_api()
and index(request->path, uri_for('/api')->path) == 0) {
my $token = request->header('Authorization');
my $user = $provider->validate_api_token($token)
or return;
session(logged_in_user => $user);
session(logged_in_user_realm => 'users');
}
else { else {
# user has no AuthN - force to handler for '/' # user has no AuthN - force to handler for '/'
request->path_info('/'); request->path_info('/');
@@ -45,16 +69,44 @@ hook 'before' => sub {
} }
}; };
# user redirected here (POST -> GET) when login fails
get qr{^/(?:login(?:/denied)?)?} => sub { get qr{^/(?:login(?:/denied)?)?} => sub {
if (request_is_api()) {
status('unauthorized');
return to_json {
error => 'not authorized',
return_url => param('return_url'),
};
}
else {
template 'index', { return_url => param('return_url') }; template 'index', { return_url => param('return_url') };
}
}; };
# override default login_handler so we can log access in the database # override default login_handler so we can log access in the database
swagger_path {
description => 'Obtain an API Key using HTTP BasicAuth',
tags => ['Global'],
parameters => [],
responses => {
default => {
examples => {
'application/json' => { api_key => 'cc9d5c02d8898e5728b7d7a0339c0785' } } },
},
},
post '/login' => sub { post '/login' => sub {
my $mode = (request->is_ajax ? 'API' : 'Web'); my $mode = (request_is_api() ? 'API' : 'WebUI');
my ($success, $realm) = authenticate_user(
param('username'), param('password') # get authN data from request (HTTP BasicAuth or Form params)
); my $authheader = request->header('Authorization');
if (defined $authheader and $authheader =~ /^Basic (.*)$/i) {
my ($u, $p) = split(m/:/, (MIME::Base64::decode($1) || ":"));
params->{username} = $u;
params->{password} = $p;
}
# validate authN
my ($success, $realm) = authenticate_user(param('username'),param('password'));
if ($success) { if ($success) {
my $user = schema('netdisco')->resultset('User') my $user = schema('netdisco')->resultset('User')
@@ -70,10 +122,16 @@ post '/login' => sub {
event => "Login ($mode)", event => "Login ($mode)",
details => param('return_url'), details => param('return_url'),
}); });
$user->update({ last_on => \'now()' }); $user->update({ last_on => \'now()' });
return if request->is_ajax; if ($mode eq 'API') {
$user->update({
token_from => time,
token => \'md5(random()::text)',
})->discard_changes();
return to_json { api_key => $user->token };
}
redirect param('return_url'); redirect param('return_url');
} }
else { else {
@@ -86,28 +144,53 @@ post '/login' => sub {
details => param('return_url'), details => param('return_url'),
}); });
if (request->is_ajax) { if ($mode eq 'API') {
status('unauthorized'); status('unauthorized');
return to_json { error => 'authentication failed' };
} }
else {
vars->{login_failed}++; vars->{login_failed}++;
forward uri_for('/login'), forward uri_for('/login'),
{ login_failed => 1, return_url => param('return_url') }, { login_failed => 1, return_url => param('return_url') },
{ method => 'GET' }; { method => 'GET' };
} }
}
}; };
# ugh, *puke*, but D::P::Swagger has no way to set this with swagger_path
# must be after the path is declared, above.
Dancer::Plugin::Swagger->instance->doc->{paths}->{'/login'}
->{post}->{security}->[0]->{BasicAuth} = [];
# we override the default login_handler, so logout has to be handled as well # we override the default login_handler, so logout has to be handled as well
any ['get', 'post'] => '/logout' => sub { swagger_path {
description => 'Destroy user API Key and session cookie',
tags => ['Global'],
parameters => [],
responses => { default => { examples => { 'application/json' => {} } } },
},
get '/logout' => sub {
my $mode = (request_is_api() ? 'API' : 'WebUI');
# clear out API token
my $user = schema('netdisco')->resultset('User')
->find({ username => session('logged_in_user')});
$user->update({token => undef, token_from => undef})->discard_changes()
if $user and $user->in_storage;
# invalidate session cookie
session->destroy;
schema('netdisco')->resultset('UserLog')->create({ schema('netdisco')->resultset('UserLog')->create({
username => session('logged_in_user'), username => session('logged_in_user'),
userip => request->remote_address, userip => request->remote_address,
event => "Logout", event => "Logout ($mode)",
details => '', details => '',
}); });
session->destroy; if ($mode eq 'API') {
return to_json {};
}
redirect uri_for('/inventory')->path; redirect uri_for('/inventory')->path;
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ use App::Netdisco::Util::Device 'delete_device';
register_worker({ phase => 'check' }, sub { register_worker({ phase => 'check' }, sub {
return Status->error('Missing device (-d).') return Status->error('Missing device (-d).')
unless defined shift->device; unless shift->device;
return Status->done('Delete is able to run'); return Status->done('Delete is able to run');
}); });
@@ -0,0 +1,32 @@
package App::Netdisco::Worker::Plugin::SetUserToken;
use Dancer ':syntax';
use Dancer::Plugin::DBIC 'schema';
use App::Netdisco::Worker::Plugin;
use aliased 'App::Netdisco::Worker::Status';
register_worker({ phase => 'check' }, sub {
return Status->error('Missing user (-e).')
unless shift->extra;
return Status->done('SetUserToken is able to run');
});
register_worker({ phase => 'main' }, sub {
my ($job, $workerconf) = @_;
my $username = $job->extra;
my $user = schema('netdisco')->resultset('User')
->find({ username => $username });
return Status->error("No such user")
unless $user and $user->in_storage;
$user->update({ token_from => time, token => \'md5(random()::text)' })
->discard_changes();
return Status->done(
sprintf 'Set token for user %s: %s', $username, $user->token);
});
true;
+5
View File
@@ -28,6 +28,7 @@ suggest_guest: false
navbar_autocomplete: true navbar_autocomplete: true
trust_remote_user: false trust_remote_user: false
trust_x_remote_user: false trust_x_remote_user: false
api_token_lifetime: 3600
#ldap: #ldap:
# servers: [] # servers: []
# user_string: 'MYDOMAIN\%USER%' # user_string: 'MYDOMAIN\%USER%'
@@ -409,6 +410,7 @@ worker_plugins:
- 'Power' - 'Power'
- 'Psql' - 'Psql'
- 'Renumber' - 'Renumber'
- 'SetUserToken'
- 'Show' - 'Show'
- 'Stats' - 'Stats'
- 'Vlan' - 'Vlan'
@@ -484,6 +486,9 @@ engines:
INCLUDE_PATH: [] INCLUDE_PATH: []
layout: 'main' layout: 'main'
plugins: plugins:
Swagger:
main_api_module: 'App::Netdisco'
ui_url: '/swagger-ui'
Auth::Extensible: Auth::Extensible:
no_api_change_warning: true no_api_change_warning: true
no_default_pages: true no_default_pages: true
@@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE users ADD COLUMN "token" text;
ALTER TABLE users ADD COLUMN "token_from" integer;
COMMIT;
Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+60
View File
@@ -0,0 +1,60 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js"> </script>
<script src="./swagger-ui-standalone-preset.js"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "https://petstore.swagger.io/v2/swagger.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en-US">
<body onload="run()">
</body>
</html>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}
arr = qp.split("&")
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}
isValid = qp.state === sentState
if ((
oauth2.auth.schema.get("flow") === "accessCode"||
oauth2.auth.schema.get("flow") === "authorizationCode"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
</script>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":[],"names":[],"mappings":"","file":"swagger-ui.css","sourceRoot":""}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
View File