File Coverage

blib/lib/WWW/REST/Apid.pm
Criterion Covered Total %
statement 49 51 96.0
branch n/a
condition n/a
subroutine 17 17 100.0
pod n/a
total 66 68 97.0


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2              
3             package WWW::REST::Apid;
4              
5             $WWW::REST::Apid::VERSION = '0.07';
6              
7 1     1   25948 use strict;
  1         2  
  1         23  
8 1     1   3 use warnings;
  1         2  
  1         21  
9              
10 1     1   347 use Carp::Heavy;
  1         141  
  1         25  
11 1     1   3 use Carp;
  1         1  
  1         51  
12              
13 1     1   453 use HTTP::Daemon;
  1         54001  
  1         9  
14 1     1   834 use HTTP::Daemon::SSL;
  1         48843  
  1         6  
15 1     1   45 use HTTP::Status qw(:constants :is status_message);
  1         2  
  1         471  
16 1     1   440 use HTTP::Request::Params;
  1         67252  
  1         4  
17 1     1   31 use URI::Escape;
  1         2  
  1         45  
18 1     1   559 use Data::Dumper;
  1         4374  
  1         45  
19 1     1   405 use CGI::Cookie;
  1         1625  
  1         23  
20 1     1   4 use MIME::Base64;
  1         1  
  1         37  
21 1     1   5 use JSON;
  1         1  
  1         6  
22 1     1   549 use Digest::SHA qw(sha256_base64);
  1         2023  
  1         56  
23 1     1   416 use Crypt::OpenSSL::Random;
  1         351  
  1         34  
24 1     1   425 use Data::Validate::Struct;
  1         142995  
  1         124  
25 1     1   299 use DB_File;
  0            
  0            
26              
27             use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $req $res);
28             require Exporter;
29             @ISA = qw(Exporter);
30             @EXPORT = qw($req $res);
31             @EXPORT_OK = qw($req $res);
32              
33              
34              
35              
36             sub new {
37             my ($class, %param) = @_;
38             my $type = ref( $class ) || $class;
39              
40             # default config
41             my %settings = (
42             host => 'localhost',
43             port => 8080,
44             timeout => 5,
45             reuseaddr => 1,
46             sessionfile => '/tmp/apid.sessions',
47             sublogin => sub { print "login not implemented\n"; return 0; },
48             log => sub { return 0; },
49             authbasic => 'WWW::REST::Apid',
50             authuri => '',
51             foreground => 0, # don't fork if true
52             );
53              
54             # internals
55             my %intern = (
56             map => {},
57             sessions => undef, # initialized later
58             server => undef, # initialized later
59             );
60              
61             # override defaults
62             foreach my $key (keys %param) {
63             $settings{$key} = $param{$key};
64             }
65              
66             my $self = \%settings;
67             bless $self, $type;
68              
69             # interns
70             foreach my $key (keys %intern) {
71             $self->{$key} = $intern{$key};
72             }
73              
74             $self->_init();
75              
76             return $self;
77             }
78              
79             sub lateconfig {
80             my($self, %hash) = @_;
81             foreach my $key (keys %hash) {
82             $self->{$key} = $hash{$key};
83             }
84             }
85              
86             sub mapuri {
87             my($self, %p) = @_;
88              
89             $self->{map}->{$p{path}} = {
90             auth => $p{doauth},
91             sub => $p{handler},
92             valid => $p{validate} ?
93             Data::Validate::Struct->new($p{validate}) : 0,
94             };
95             }
96              
97              
98              
99             sub run {
100             my $self = shift;
101              
102             $self->{log}("listening on $self->{host}:$self->{port}");
103              
104             if (! $self->{foreground}) {
105             $self->{_old_sig_pipe_handler} = $SIG{'PIPE'};
106             $SIG{'PIPE'} = 'IGNORE';
107             $SIG{CHLD} = 'IGNORE';
108             }
109              
110             while (1) {
111             my $conn = $self->{server}->accept or next;
112              
113             if (! $self->{foreground}) {
114             next if fork;
115             }
116              
117             $self->_loadsessions();
118             my ($r_port, $r_iaddr) = Socket::unpack_sockaddr_in($conn->peername);
119             my $ip = Socket::inet_ntoa($r_iaddr);
120              
121             while (my $req = $conn->get_request) {
122             $req->{remote_ip} = $ip;
123             $req->{remote_port} = $r_port;
124             $req->{path_info} = $req->uri->path;
125              
126             my $res = HTTP::Response->new;
127             $res->code(200);
128             $res = $self->_process($req, $res); # makes them global
129              
130             $conn->send_response($res);
131              
132             $self->{log}(join(' ', ($res->{user}, $ip, $res->code, $req->method, $req->uri->path)));
133             }
134             $self->_dumpsessions();
135             exit if(! $self->{foreground});
136             }
137             $self->{log}("apid ended");
138             }
139              
140              
141              
142              
143             sub _init {
144             my $self = shift;
145              
146             # check if ssl mode requested
147             if (exists $self->{sslcrt} && exists $self->{sslkey}) {
148             my %ssl;
149             foreach my $key (keys %{$self}) {
150             if ($key =~ /^SSL/) {
151             $ssl{$key} = $self->{$key};
152             }
153             }
154             $self->{server} = HTTP::Daemon::SSL->new(
155             LocalPort => $self->{port},
156             LocalHost => $self->{host},
157             ReuseAddr => $self->{reuseaddr},
158             Timeout => $self->{timeout},
159             SSL_key_file => $self->{sslkey},
160             SSL_cert_file => $self->{sslcrt},
161             %ssl
162             ) or croak "Cannot start listener: $!\n";
163             }
164             else {
165             $self->{server} = HTTP::Daemon->new(
166             LocalPort => $self->{port},
167             LocalHost => $self->{host},
168             ReuseAddr => $self->{reuseaddr},
169             Timeout => $self->{timeout},
170             ) or croak "Cannot start listener: $!\n";
171             }
172             }
173              
174              
175              
176              
177              
178              
179              
180              
181             sub _authheader {
182             my $self = shift;
183             $res->header('WWW-Authenticate' => 'Basic realm=' . $self->{authbasic});
184             $res->code(HTTP_UNAUTHORIZED);
185             $res->header('Content-type' => 'application/json; charset=UTF-8');
186             $res->add_content("{ \"error\": \"please authenticate\" }");
187             return 0;
188             }
189              
190             sub _doauthredir {
191             my $self = shift;
192             ($req, $res) = @_;
193             my $data;
194              
195             if ($req->content) {
196             if ($req->content =~ /^\{/) {
197             eval { $data = decode_json($req->content); };
198             }
199             else {
200             # try decoding as query
201             my $query = HTTP::Request::Params->new({ req => $req });
202             $data = $query->params;
203             delete $data->{keywords};
204             }
205             }
206              
207             if ($data) {
208             if (exists $data->{user} && exists $data->{pass}) {
209             if ($self->{sublogin}->($data->{user}, $data->{pass})) {
210             $self->_dosession($data->{user});
211             $res->header('Content-type' => 'application/json; charset=UTF-8');
212             $res->add_content("{ \"info\": \"authenticated\" }");
213             return 1;
214             }
215             }
216             }
217              
218             $res->code(HTTP_UNAUTHORIZED);
219             $res->header('Content-type' => 'application/json; charset=UTF-8');
220             $res->add_content("{ \"error\": \"please authenticate\" }");
221              
222             return 1;
223             }
224              
225              
226             sub _authredir {
227             my $self = shift;
228             $res->{target_uri} = URI::http->new($self->{authuri});
229             $res->code(302);
230             return 0;
231             }
232              
233              
234             sub _doauth {
235             my $self = shift;
236              
237             if ($req->header('Cookie')) {
238             my $rawcookie = $req->header('Cookie');
239             if ($rawcookie =~ /^Session=(.*)$/) {
240             my $session = uri_unescape($1);
241             if (exists $self->{ses}->{$session}) {
242             # ok, session known, user already authenticated
243             my ($user, $time) = split /,/, $self->{ses}->{$session};
244             if (time - $time < 86400) {
245             # ok, cookie age within bounds
246             $res->{user} = $user;
247             return 1;
248             }
249             }
250             }
251             }
252              
253             # no session
254             if ($self->{authbasic}) {
255             return $self->_doauthbasic();
256             }
257             else {
258             return $self->_authredir();
259             }
260             }
261              
262             sub _doauthbasic {
263             # no session, basic auth
264             my $self = shift;
265              
266             my $auth = $req->header('Authorization');
267             if (! $auth) {
268             return $self->_authheader();
269             }
270             else {
271             my ($basic, $b64) = split /\s\s*/, $auth;
272             my $clear = decode_base64($b64);
273             my($user, $pass) = split /:/, $clear;
274             if (! $self->{sublogin}->($user, $pass)) {
275             return $self->_authheader();
276             }
277             else {
278             $self->_dosession($user);
279             $res->header('WWW-Authenticate' => 'Basic realm="apid"');
280             }
281             }
282             return 1;
283             }
284              
285             sub _dosession {
286             my ($self, $user) = @_;
287              
288             my $session = sha256_base64(Crypt::OpenSSL::Random::random_bytes(64));
289             $self->{ses}->{$session} = $user . "," . time;
290             my $cookie = CGI::Cookie->new(
291             -name => 'Session',
292             -expires => '+1d',
293             -value => $session);
294             $res->header('Set-Cookie' => $cookie);
295             }
296              
297             sub _process {
298             my $self = shift;
299             ($req, $res) = @_;
300              
301             my $fail = 1;
302             my $path = '';
303             my $found = 0;
304             my $jsonop = JSON->new->allow_nonref;
305              
306             if (! $req->{path_info}) {
307             $req->{path_info} = '/';
308             }
309              
310             foreach my $path (sort { length($b) <=> length($a) } keys %{$self->{map}}) {
311             if ($path eq $req->{path_info}) {
312             $found = 1;
313             if ($self->{map}->{$path}->{auth}) {
314             if (! $self->_doauth()) {
315             last; # auth requested, user unauthenticated, else continue
316             }
317             }
318             my $remainder = $req->{path_info};
319             $remainder =~ s/\Q$path\E//;
320             $req->{path_info} = $remainder;
321             my $go = $self->{map}->{$path}->{sub};
322             my ($put, $hash);
323              
324             if ($req->content) {
325             if ($req->content =~ /^\{/) {
326             eval { $put = decode_json($req->content); };
327             if ($@) {
328             $@ =~ s/ at $0 line.*//;
329             $@ = "JSON Parser Error: $@";
330             last;
331             }
332             }
333             else {
334             # try decoding as query
335             my $query = HTTP::Request::Params->new({ req => $req });
336             $put = $query->params;
337             delete $put->{keywords};
338             }
339             }
340             else {
341             # maybe there were cgi get params
342             my $query = HTTP::Request::Params->new({ req => $req });
343             $put = $query->params;
344             delete $put->{keywords};
345             }
346              
347             if ($self->{map}->{$path}->{valid}) {
348             my $ok;
349             eval { $ok = $self->{map}->{$path}->{valid}->validate($put); };
350             if (! $ok || $@) {
351             $@ = $self->{map}->{$path}->{valid}->errstr();
352             chomp $@;
353             $@ =~ s/ at .*$//;
354             last;
355             }
356             }
357              
358             eval { $hash = $go->($put); };
359              
360             if (!$@) {
361             if ($hash) {
362             my $json = encode_json($hash);
363             $res->add_content("$json");
364             }
365             $fail = 0;
366             }
367              
368             last;
369             }
370             }
371              
372             if (!$found) {
373             $res->code(404);
374             }
375             else {
376             if (! $res->header('Content-type')) {
377             $res->header('Content-type' => 'application/json; charset=UTF-8');
378             }
379              
380             if ($fail) {
381             $res->code(403);
382             $res->add_content("{ \"error\": \"$@ $!\" }");
383             }
384             }
385              
386             $res->{user} = $res->{user} ? $res->{user} : '-';
387              
388             return $res;
389             }
390              
391              
392             sub _dumpsessions {
393             my $self = shift;
394             untie %{$self->{ses}};
395             }
396              
397             sub _loadsessions {
398             my $self = shift;
399             tie %{$self->{ses}}, 'DB_File', $self->{sessions}, O_CREAT|O_RDWR, 0600, $DB_HASH;
400             }
401              
402              
403              
404              
405             1;
406              
407              
408              
409             =head1 NAME
410              
411             WWW::REST::Apid - Generic REST API Module
412              
413             =head1 SYNOPSIS
414              
415             use WWW::REST::Apid;
416             use Authen::Simple::LDAP;
417            
418             my $server = WWW::REST::Apid->new(
419             host => 'localhost',
420             port => 8080,
421             apiname => 'my api',
422             apiversion => '1.0',
423             authbasic => 1,
424             sublogin => sub {
425             my($user, $pass) = @_;
426             my $ldap = Authen::Simple::LDAP->new(
427             host => 'ldap.company.com',
428             basedn => 'ou=People,dc=company,dc=net'
429             );
430             if ( $ldap->authenticate( $user, $pass ) ) {
431             return 1; # ok
432             }
433             return 0; # fail
434             },
435             log => sub { my $msg = shift; syslog('info', $msg); },
436             foreground => 0,
437             );
438            
439             $server->mapuri(path => '/', doauth => 1, handler => sub { return { msg => 'ok' } });
440            
441             $server->run();
442              
443             =head1 DESCRIPTION
444              
445             The WWW::REST::Apid module can be used to implement a REST API
446             for almost anything.
447              
448             If you want fast and easy results, please try the L daemon,
449             which is shipped with the WWW::REST::Apid distribution, first.
450              
451             =head1 METHODS
452              
453             =head2 B
454              
455             The new method returns a new WWW::REST::Apid object. All parameters
456             are optional and will be preset with reasonable defaults.
457              
458             Supported parameters:
459              
460             =over
461              
462             =item B
463              
464             The hostname or ip address where the daemon will listen to.
465             Default: 'localhost'.
466              
467             =item B
468              
469             The TCP port to use. Default: 8080.
470              
471             =item B
472              
473             The name of your API.
474              
475             =item B
476              
477             The version of your API.
478              
479             =item B
480              
481             Use HTTP Basic Authentication. The parameter defines the realm.
482              
483             =item B
484              
485             Use HTTP POST Authentication with login uri redirect for unauthenticated
486             users.
487              
488             =item B
489              
490             Expects a closure as parameter. The closure gets two parameters supplied
491             during the login process: the username and the password supplied by the
492             client in clear text.
493              
494             If authentication shall succeed, return 1, otherwise 0.
495              
496             Default: returns always false.
497              
498             =item B
499              
500             Logging function to use, expects a closure as parameter. One parameter
501             will be given to the closure: the log message. Put it where ever you
502             want.
503              
504             Default: ignore.
505              
506             =item B
507              
508             If set to true, the daemon doesn't fork a child process for new
509             incoming connections, which it does otherwise. If you work with
510             a preforking system as L, then set it to true. If
511             you use something like L, set it to false.
512              
513             Default: false.
514              
515             =item B AND B
516              
517             If both are given, files are expected. B must be a X509 PEM
518             encoded SSL certificate, B must be a PEM encoded SSL unencrypted
519             private key for the certificate.
520              
521             =item Bnew() parameters>
522              
523             Any parameter starting with 'SSL' will be fed unaltered to IO::Socket::SSL->new().
524              
525             =back
526              
527             =head2 B
528              
529             Supply any of the above mentioned parameters at some later point,
530             which allows to re-configure certain aspects of the daemon. Some variables
531             cannot be changed once the daemon runs, especially the host and port
532             variables.
533              
534             Expects the parameters as a hash. Example:
535              
536             $server->lateconfig(authuri => '/login');
537              
538             =head2 B
539              
540             The B method is the heart of the module. It expects hash parameters.
541              
542             Example:
543              
544             $server->mapuri(path => '/', doauth => 1, handler => sub { return { msg => 'ok' } });
545              
546             The following parameters are supported:
547              
548             =over
549              
550             =item B
551              
552             Required: the uri path which shall be mapped to some action.
553              
554             =item B
555              
556             Required: closure is expected as parameter. The closure gets as its only
557             argument a hash reference supplied which contains data posted by
558             a client (either via POST, PUT or GET query params).
559              
560             It is expected to return a hash reference with results again.
561              
562             JSON conversion will be done automatically.
563              
564             You can access the current L object within the
565             handler by using the variable B<$req> and the L
566             object with B<$res>.
567              
568             =item B
569              
570             Optional: turn on authentication for this particular path.
571              
572             The B closure must be imlemented.
573              
574             =item B
575              
576             Optional: a hash reference describing the input validation
577             using the notation of L. If defined,
578             data posted by clients will be validated and if found to be
579             invalid, an error will be returned.
580              
581             =back
582              
583             =head2 B
584              
585             Finally, start the server.
586              
587             This method never returns.
588              
589             =head1 AUTHOR
590              
591             T.v.Dein
592              
593             =head1 BUGS
594              
595             Report bugs to
596             http://rt.cpan.org/NoAuth/ReportBug.html?Queue=WWW-REST-Apid
597              
598             =head1 SEE ALSO
599              
600             L
601             L
602             L
603             L
604             L
605             L
606              
607             =head1 COPYRIGHT
608              
609             Copyright (c) 2014-2017 by T.v.Dein .
610             All rights reserved.
611              
612             =head1 LICENSE
613              
614             This program is free software; you can redistribute it
615             and/or modify it under the same terms as Perl itself.
616              
617             =head1 VERSION
618              
619             apid Version 0.07.
620              
621             =cut
622