-
Notifications
You must be signed in to change notification settings - Fork 0
/
check_saml_sso
executable file
·315 lines (279 loc) · 16.1 KB
/
check_saml_sso
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
#!/usr/bin/env perl
# @author Guy Halse http://orcid.org/0000-0002-9388-8592
# @copyright Copyright (c) 2017, Tertiary Education and Research Network of South Africa
# @license https://github.com/tenet-ac-za/monitoring-plugins/blob/master/LICENSE MIT License
#
use strict;
use warnings;
use Date::Parse;
use HTTP::Request;
use LWP::UserAgent;
use Monitoring::Plugin;
use POSIX;
use Time::HiRes qw(time);
use URI;
use URI::Escape;
use vars qw ($hopCount $phase);
our $VERSION = '0.3';
# verify the certificate date
sub SSL_verify_callback
{
if ($main::np->opts->certificate) {
my ($warning,$critical) = split /,/, $main::np->opts->certificate;
$critical = $warning unless defined ($critical);
my $cert = $_[4];
my $notAfter = Net::SSLeay::P_ASN1_TIME_get_isotime(Net::SSLeay::X509_get_notAfter($cert));
my $daysLeft = int((str2time($notAfter) - $main::startTime)/86400);
my $subject = Net::SSLeay::X509_NAME_get_text_by_NID(Net::SSLeay::X509_get_subject_name($cert), &Net::SSLeay::NID_commonName);
if ($critical && $critical >= $daysLeft) {
$main::np->add_message(CRITICAL, sprintf("SSL certificate '%s' expires in %d day(s) (%s).", $subject, $daysLeft, $notAfter));
} elsif ($warning && $warning >= $daysLeft) {
$main::np->add_message(WARNING, sprintf("SSL certificate '%s' expires in %d day(s) (%s).", $subject, $daysLeft, $notAfter));
}
}
return 1;
}
sub cleanURI($)
{
my $uri = URI->new(shift);
$uri->query(undef);
$uri->fragment(undef);
return $uri->as_string;
}
# prototype because calls itself
sub recurseRedirects($$$;$$);
sub recurseRedirects($$$;$$)
{
my ($np, $ua, $uri, $method, $postdata) = @_;
my ($ret);
if ($hopCount++ > 20) { # match Chrome's limit
$np->plugin_die('Phase 1 too many redirects', CRITICAL);
}
my $req = new HTTP::Request;
$req->method(defined ($method) ? $method : 'GET');
$req->uri($uri);
if ($req->method eq 'POST') {
$req->header('Content-Type' => 'application/x-www-form-urlencoded');
$req->content($postdata)
}
print STDERR 'Phase ' . $phase . ': Request ' . $req->as_string . "\n" if $np->opts->verbose;
my $res = $ua->simple_request($req);
if ($np->opts->skew && $res->header('Date')) {
my $skew_check = $np->check_threshold('check' => $res->current_age(), 'warning' => $np->opts->skew);
print STDERR 'Phase ' . $phase . ': Clock skew for ' . $req->uri->host . ': date=' . $res->header('Date') . ', age=' . $res->current_age() . "\n" if $np->opts->verbose;
$np->add_message($skew_check, 'Clock skew for ' . $req->uri->host . ' may be out of tolerance (current age ' . $res->current_age() . ' secs)') if $skew_check != OK;
}
if ($res->is_error && !( $np->opts->auth401 && $res->code == HTTP::Status::HTTP_UNAUTHORIZED )) {
if ($res->code == HTTP::Status::HTTP_REQUEST_TIMEOUT) {
$np->plugin_die("Phase $phase Timeout!", WARNING) ;
} elsif ($res->code == HTTP::Status::HTTP_INTERNAL_SERVER_ERROR) {
# Special cases to match specific software (e.g. SimpleSAMLphp)
if ($res->decoded_content =~ m/metadata.*expired/si) {
$np->add_message(CRITICAL, 'IdP appears have out-of-date federation metadata (expired?)');
} elsif ($res->decoded_content =~ m/metadata.*(missing|not\s+found)/si) {
$np->add_message(CRITICAL, 'IdP appears to be missing federation metadata (not found?)');
}
}
$np->add_message(WARNING, 'Unexpected ' . $res->status_line . ' response at ' . cleanURI($uri));
$ret = $res; # break condition
} elsif ($res->is_redirect) {
my $new_uri = $res->header('location');
$np->plugin_die('Phase '.$phase.' redirect loop at '.$new_uri, WARNING) if $res->request->uri->eq($new_uri);
$np->add_message(OK, "Redirected to " . cleanURI($new_uri));
print STDERR 'Phase '.$phase.': Redirected to ' . $new_uri . "\n" if $np->opts->verbose;
$ret = recurseRedirects($np, $ua, $new_uri, 'GET');
} elsif ($res->is_success || ( $np->opts->auth401 && $res->code == HTTP::Status::HTTP_UNAUTHORIZED )) {
my ($samlrequestfield) = $res->decoded_content() =~ m/(\<\s*input\s+[^>]*SAMLRequest[^>]+\>)/si;
if (defined $samlrequestfield and $samlrequestfield) {
my ($samlrequest) = $samlrequestfield =~ m/\s+value\s*=\s*["']?([^"]+)["']?(?:\s*\\?>|\s+)/si;
my ($method) = $res->decoded_content() =~ m/\<form\s+[^>]*method=["']?([^"]+)["']?(?:\s*\\?>|\s+)/si;
my ($action) = $res->decoded_content() =~ m/\<form\s+[^>]*action=["']?([^"]+)["']?(?:\s*\\?>|\s+)/si;
print STDERR 'Phase '.$phase.".5: POST to " . $action . " using HTTP " . $method . "\n" if $np->opts->verbose;
$np->add_message(OK, "POST to " . $action);
$ret = recurseRedirects($np, $ua, $action, 'POST', {'SAMLRequest' => $samlrequest,});
} else {
$ret = $res; # break condition
}
}
return $ret;
}
our $np = Monitoring::Plugin->new(
'usage' => 'Usage: %s [ -v|--verbose ] [-H <vhost>] [-I <address>] [-t <timeout>] [-u <url>]',
'license' => 'MIT License <https://github.com/tenet-ac-za/monitoring-plugins/blob/master/LICENSE>',
'version' => $VERSION,
'blurb' => "A simple plugin to check SimpleSAMLphp SSO with loginuserpass.php or loginuserpassorg.php via the autotest module.",
'extra' => "
While written for SSP's autotest module, this plugin should be usable more
generically for any IdP that implements single-factor username/password
authentication via a web form and complies with the SAML2int Interoperable
SAML 2.0 WebSSO Deployment Profile. Since the plugin scrapes HTML to find
the necessary SAML fields in an HTTP-POST binding and for the actual login
form, the structure of these pages may be the limiting factor.
",
);
$np->add_arg('spec' => 'url|u=s', 'help' => 'URL to fetch (default: /)', 'default' => '/',);
$np->add_arg('spec' => 'host|H=s', 'help' => 'Hostname to use for vhost', 'required' => 1);
$np->add_arg('spec' => 'address|I=s', 'help' => 'IP address or name (use numeric address if possible to bypass DNS lookup).');
$np->add_arg('spec' => 'port|p=i', 'help' => 'Port to use (defaults to 80 or 443)',);
$np->add_arg('spec' => 'ssl|S', 'help' => 'Use SSL');
$np->add_arg('spec' => 'user|U=s', 'help' => 'Username');
$np->add_arg('spec' => 'userfield=s', 'help' => 'Username field (default=username)', 'default' => 'username');
$np->add_arg('spec' => 'pass|P=s', 'help' => 'Password');
$np->add_arg('spec' => 'passfield=s', 'help' => 'Password field (default=password)', 'default' => 'password');
$np->add_arg('spec' => 'org|O=s', 'help' => 'Organisation/realm to add if using loginuserpassorg.php',);
$np->add_arg('spec' => 'orgfield=s', 'help' => 'Organisation field (default=organization)', 'default' => 'organization');
$np->add_arg('spec' => 'redirectonly|R', 'help' => 'Test redirects only (no login)', 'default' => 0);
$np->add_arg('spec' => 'ok|k=s', 'help' => 'String to look for in final response from SP (default=OK)', 'default' => 'OK');
$np->add_arg('spec' => 'certificate|C=s', 'help' => 'Minimum number of days a certificate has to be valid (same format as check_http)');
$np->add_arg('spec' => 'renewinfo=s', 'help' => 'Additional message to include in certificate warnings', 'default' => '');
$np->add_arg('spec' => 'metadatacertinfo=s', 'help' => 'Internal option for passing metadata certificate expiry data');
$np->add_arg('spec' => 'auth401:1', 'help' => 'Accept a 401 Authorisation Required response (unusual)', 'default' => 0);
$np->add_arg('spec' => 'skew|s=s', 'help' => 'Attempt to check clock skew is within thresholds using webserver Date: header (default 300 [5 mins])', 'default' => '300');
$np->getopts;
my $port = $np->opts->port ? $np->opts->port : ( $np->opts->ssl ? 443 : 80);
my $uri = new URI(($np->opts->ssl ? 'https' : 'http') . '://' . ($np->opts->address ? $np->opts->address : $np->opts->host) . ':' . $port . $np->opts->url, $np->opts->ssl ? 'https' : 'http');
my $userfield = scalar $np->opts->userfield;
my $passfield = scalar $np->opts->passfield;
my $okstring = scalar $np->opts->ok;
# perfdata
$hopCount = -1;
$phase = 0;
our $startTime = time();
# check metadata certificate expiry (hard-coded into config)
if ($np->opts->certificate && $np->opts->metadatacertinfo) {
my ($warning,$critical) = split /,/, $np->opts->certificate;
$critical = $warning unless defined ($critical);
foreach my $cert (split /\|/, $np->opts->metadatacertinfo) {
my ($notAfter, $subject) = split /\//, $cert;
my $daysLeft = int(($notAfter - $startTime)/86400);
if ($daysLeft <= 0) {
$np->add_message(CRITICAL, sprintf("SAML certificate '%s' from metadata has EXPIRED. Users may not be able to authenticate. (%s)", $subject, POSIX::strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($notAfter))));
} elsif ($critical && $critical >= $daysLeft) {
$np->add_message(CRITICAL, sprintf("SAML certificate '%s' from metadata expires in %d day(s) (%s).", $subject, $daysLeft, POSIX::strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($notAfter))));
$np->add_message(WARNING, $np->opts->renewinfo) if $np->opts->renewinfo;
} elsif ($warning && $warning >= $daysLeft) {
$np->add_message(WARNING, sprintf("SAML certificate '%s' from metadata expires in %d day(s) (%s).", $subject, $daysLeft, POSIX::strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($notAfter))));
$np->add_message(WARNING, $np->opts->renewinfo) if $np->opts->renewinfo;
}
}
}
print STDERR "Set URI to $uri\n" if $np->opts->verbose;
$np->add_message(OK, "Initiate SSO at " . cleanURI($uri));
#$ENV{'PERL_LWP_SSL_VERIFY_HOSTNAME'} = 0;
my $ua = LWP::UserAgent->new;
$ua->timeout($np->opts->timeout);
$ua->cookie_jar({});
$ua->agent('Mozilla/5.0 (X11; Linux x86_64) check_saml_sso/' . $VERSION . ' ' . $ua->_agent);
$ua->ssl_opts('SSL_verify_callback' => \&SSL_verify_callback);
$ua->ssl_opts('SSL_ca_path' => '/etc/ssl/certs');
# Phase 1: SP -> SSO
$phase = 1;
# Assumes HTTP-Redirect or HTTP-POST binding for SingleSignOnService
my $res = recurseRedirects($np, $ua, $uri);
# Phase 2: SSO Login
$phase = 2;
unless ($res->is_success) {
if ($res->code == HTTP::Status::HTTP_UNAUTHORIZED) {
$np->add_message($np->opts->auth401 ? OK : WARNING, 'SSO login page uses HTTP authentiction (this is unusual' . ($np->opts->auth401 ? ' but allowed for this entity' : '') . ')');
} else {
$np->add_message(CRITICAL, 'SSO Login Page HTTP ' . $res->code . ' ' . $res->message);
}
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
$np->plugin_exit($code, ' ' . $message);
}
print STDERR $res->decoded_content . "\n" if $np->opts->verbose > 1;
if ($res->decoded_content =~ m/$userfield/i) {
$np->add_message(OK, "Userfield found matching: " . $userfield);
} else {
$np->add_message(WARNING, "NO userfield found matching: " . $userfield);
}
# don't try login
if ($np->opts->redirectonly) {
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
if ($code == OK) {
$message = "Login page found at " . cleanURI($res->request->uri) . "\n\n" . $message;
}
$np->add_perfdata('label' => 'hops', 'value' => $hopCount, 'min' => 1, 'max' => $ua->max_redirect * 3);
$np->add_perfdata('label' => 'rtt', 'value' => sprintf("%0.2f", time() - $startTime), 'uom' => 's', 'min' => 0, 'max' => $np->opts->timeout);
$np->add_perfdata('label' => 'skew', 'value' => $res->current_age(), 'uom' => 's', 'min' => 0);
$np->plugin_exit($code, ' ' . $message);
}
my ($authstatefield) = $res->decoded_content() =~ m/(\<\s*input\s+[^>]*AuthState[^>]+\>)/si;
$np->plugin_exit(CRITICAL, 'Phase 2: AuthState field missing') unless defined $authstatefield;
my ($authstate) = $authstatefield =~ m/\s+value\s*=\s*["']?([^"]+)["']?(?:\s*\/?>|\s+)/si;
$np->plugin_exit(CRITICAL, 'Phase 2: AuthState missing') unless defined $authstate;
print STDERR "Phase 2: AuthState " . $authstate . "\n" if $np->opts->verbose > 1;
my $res3 = $ua->post($res->request->uri, 'Content' => {
$np->opts->userfield => $np->opts->user,
$np->opts->passfield => $np->opts->pass,
$np->opts->orgfield => $np->opts->org,
'AuthState' => $authstate,
});
$np->plugin_die("Phase 2 Timeout!", WARNING) if $res3->code == HTTP::Status::HTTP_REQUEST_TIMEOUT;
foreach my $r ($res3->redirects) {
next if $r->request->uri->eq($res->request->uri);
$np->add_message(OK, "Redirected to " . cleanURI($r->request->uri));
print STDERR "Phase 2: Redirected to " . $r->request->uri . "\n" if $np->opts->verbose;
}
unless ($res3->request->uri->eq($res->request->uri)) {
$np->add_message(OK, "Redirected to " . cleanURI($res3->request->uri));
print STDERR "Phase 2: Redirected to " . $res3->request->uri . "\n" if $np->opts->verbose;
}
if (!$res3->is_success()) {
$np->add_message(CRITICAL, 'SSO Authenticate HTTP ' . $res3->code . ' ' . $res3->message);
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
$np->plugin_exit($code, $message);
}
print STDERR "Phase 2: HTTP response " . $res3->code . "\n" if $np->opts->verbose;
# Phase 3: SSO Response
$phase = 3;
if ($res3->decoded_content() =~ m/\<\s*input\s+[^>]+($userfield|$passfield)/) {
$np->add_message(CRITICAL, 'Looped back to username/password input (authentication failed?)');
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
$np->plugin_exit($code, $message);
}
my ($samlendpointfield) = $res3->decoded_content() =~ m/(\<\s*form\s+[^>]*action[^>]+\>)/si;
my ($samlendpoint) = $samlendpointfield =~ m/\s+action\s*=\s*["']?([^"]+)["']?(?:\s*\\?>|\s+)/si;
print STDERR "Phase 3: SAML end-point " . $samlendpoint . "\n" if $np->opts->verbose > 1;
my ($samlresponsefield) = $res3->decoded_content() =~ m/(\<\s*input\s+[^>]*SAMLResponse[^>]+\>)/si;
my ($samlresponse) = $samlresponsefield =~ m/\s+value\s*=\s*["']?([^"]+)["']?(?:\s*\\?>|\s+)/si;
print STDERR "Phase 3: SAMLResponse " . $samlresponse . "\n" if $np->opts->verbose > 1;
my ($relaystatefield) = $res3->decoded_content() =~ m/(\<\s*input\s+[^>]*RelayState[^>]+\>)/si;
my ($relaystate) = $relaystatefield =~ m/\s+value\s*=\s*["']?([^"]+)["']?(?:\s*\\?>|\s+)/si;
print STDERR "Phase 3: RelayState " . $relaystate . "\n" if $np->opts->verbose > 1;
if (not $samlresponse and not $relaystate) {
$np->add_message(CRITICAL, 'Phase 3: No SAML response and/or relaystate');
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
$np->plugin_exit($code, $message);
} else {
$np->add_message(OK, 'Login succeeded for '.$np->opts->user);
}
# Phase 4: SSO -> SP
$phase = 4;
if ($okstring ne '') {
# Assumes an HTTP-POST binding for AssertionConsumerService
my $res4 = recurseRedirects($np, $ua, $samlendpoint,
'POST', 'SAMLResponse=' . uri_escape($samlresponse) . '&RelayState=' . uri_escape($relaystate)
);
if (!$res4->is_success()) {
$np->add_message(CRITICAL, 'SSO AssertionConsumerService HTTP ' . $res4->code . ' ' . $res4->message);
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
$np->plugin_exit($code, $message);
}
print STDERR "Phase 4: HTTP response " . $res4->code . "\n" if $np->opts->verbose;
if ($res4->decoded_content() =~ m/$okstring/i) {
$np->add_message(OK, 'SP AssertionConsumerService found matching "' . $okstring . '"');
} else {
$np->add_message(WARNING, 'SP AssertionConsumerService did not match "' . $okstring . '"');
}
}
# All Done :-)
$phase = 5;
my ($code, $message) = $np->check_messages('join' => "\n", 'join_all' => "\n\n");
if ($code == OK) {
$message = "Authentiction at " . cleanURI($res3->request->uri) . " succeeded\n\n" . $message;
}
$np->add_perfdata('label' => 'hops', 'value' => $hopCount, 'min' => 1, 'max' => $ua->max_redirect * 5);
$np->add_perfdata('label' => 'rtt', 'value' => sprintf("%0.2f", time() - $startTime), 'uom' => 's', 'min' => 0, 'max' => $np->opts->timeout);
$np->add_perfdata('label' => 'skew', 'value' => $res->current_age(), 'uom' => 's', 'min' => 0);
$np->plugin_exit($code, ' '.$message);