chiark / gitweb /
3c0bd71063d2af1751670269134cc4943e81e848
[cgi-auth-flexible.git] / cgi-auth-hybrid.pm
1 # -*- perl -*-
2
3 # This is part of CGI::Auth::Hybrid, a perl CGI authentication module.
4 # Copyright (C) 2012 Ian Jackson.
5 # Copyright (C) 2012 Citrix.
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 BEGIN {
21     use Exporter   ();
22     our ($VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS);
23
24     $VERSION     = 1.00;
25     @ISA         = qw(Exporter);
26     @EXPORT      = qw();
27     %EXPORT_TAGS = ( );     # eg: TAG => [ qw!name1 name2! ],
28
29     @EXPORT_OK   = qw(setup);
30 }
31 our @EXPORT_OK;
32
33 use DBI;
34 use CGI;
35 use Locale::Gettext;
36
37 #---------- public utilities ----------
38
39 sub flatten_params ($) {
40     my ($p) = @_;
41     my @p;
42     foreach my $k (keys %$p) {
43         foreach my $v (@{ $p->{$k} }) {
44             push @p, $k, $v;
45         }
46     }
47     return @p;
48 }
49
50 #---------- default callbacks ----------
51
52 sub has_a_param ($$) {
53     my ($c,$cn) = @_;
54     foreach my $pn (@{ $r->{S}{$cn} }) {
55         return 1 if $r->_cm('get_param')($pn);
56     }
57     return 0;
58 }
59
60 sub get_params ($$) {
61     my ($c) = @_;
62     my %p;
63     foreach my $name ($c->param()) {
64         $p{$name} = [ $c->param($name) ];
65     }
66     return \%p;
67 }
68
69 sub get_cookie_domain ($$$) {
70     my ($c,$r) = @_;
71     my $uri = new URI $r->_ch('get_url');
72     return $uri->host();
73 }
74
75 sub construct_cookie ($$$) {
76     my ($r, $cookv) = @_;
77     return $r->{Cgi}->cookie(-name => $r->{S}{cookie_name},
78                              -value => $cookv,
79                              -path => $r->{S}{cookie_path},
80                              -domain => $r->_ch('get_cookie_domain'),
81                              -expires => '+'.$r->{S}{login_timeout}.'s',
82                              -secure => $r->{S}{encrypted_only});
83 }
84
85 sub login_ok_password ($$) {
86     my ($c, $r) = @_;
87     my $username_params = $r->{S}{username_param_names};
88     my $username = $r->_ch('get_param')($username_params->[0]);
89     my $password = $r->_rp('password_param_name');
90     return $r->_ch('username_password_ok', $username, $password);
91 }
92
93 sub do_redirect_cgi ($$$$) {
94     my ($c, $r, $new_url, $cookie) = @_;
95     my @ha = ('text/html',
96               -status => '303 See other',
97               -location => $new_url);
98     push @ha, (-cookie => $cookie) if defined $cookie;
99     $r->_print($c->header(@ha),
100                $r->_ch('gen_start_html')($r->_gt('Redirection')),
101                '<a href="'.escapeHTML($new_url).'">',
102                $r->_gt("If you aren't redirected, click to continue."),
103                "</a>",
104                $c->_ch('gen_end_html'));
105 }
106
107 sub gen_plain_login_form ($$) {
108     my ($c,$r, $params) = @_;
109     my @form;
110     push @form, ('<form method="POST" action="'.
111                  escapeHTML($r->_ch('get_url')).'>'.
112                  '<table>');
113     my $sz = 'size="'.$r->{S}{form_entry_size}.'"';
114     foreach my $up (@{ $r->{S}{username_param_names}}) {
115         push @form, ('<tr><td>',$r->_gt(ucfirst $up),'</td>',
116                      '<td><input type="text" ',$sz.
117                      ' name=',$up,'></td></tr>');
118     }
119     push @form, ('<tr><td>'.$r->_gt('Password'),'</td>',
120                  '<td><input type="password" '.$sz.
121                  ' name="'.$r->{S}{password_param_name}.'"></td></tr>');
122     push @form, ('<tr><td colspan="2">',
123                  '<input type="submit"'.
124                  ' name="'.$r->{S}{login_submit_name}.'"'.
125                  ' value="'.$r->_gt('Login').'"></td></tr>',
126                  '</table>');
127     foreach my $n (keys %$params) {
128         push @form, ('<input type="hidden"'.
129                      ' name="'.$n.'"'.
130                      ' value="'.$params->{$n}.'">');
131     }
132     push @form, ('</form>');
133     return join "\n", @form;
134 }
135
136 sub gen_login_link ($$) {
137     my ($c,$r, $params) = @_;
138     my $url = $r->url_with_query_params($params);
139     return ('<a href="'.escapeHTML($url).'">'.
140             $r->_gt('Log in again to continue.').
141             '</a>');
142 }
143
144 #---------- verifier object methods ----------
145
146 sub new_verifier {
147     my $class = shift;
148     my $s = {
149         S => {
150             assocdb_path => 'cah-assocs.db';
151             assocdb_dsn => undef,
152             assocdb_user => '',
153             assocdb_password => '',
154             assocdb_table => 'assocs',
155             random_source => '/dev/urandom',
156             associdlen => 128, # bits
157             login_timeout => 86400, # seconds
158             assoc_param_name => 'cah_associd',
159             password_param_name => 'password',
160             username_param_names => [qw(username)],
161             form_entry_size => 60,
162             logout_param_names => [qw(cah_logout)],
163             login_submit_name => [qw(cah_login)],
164             loggedout_param_names => [qw(cah_loggedout)],
165             promise_check_mutate => 0,
166             get_param => sub { $_[0]->param($_[2]) },
167             get_params => sub { $_[1]->get_params() },
168             get_cookie => sub { $_[0]->cookie($s->{S}{cookie_name}) },
169             get_method => sub { $_[0]->request_method() },
170             get_url => sub { $_[0]->url(); },
171             is_login => sub { defined $_[1]->_rp('password_param_name') },
172             login_ok => \&login_ok_password,
173             username_password_ok => sub { die },
174             is_logout => sub { $_[1]->has_a_param('logout_param_names') },
175             is_loggedout => sub { $_[1]->has_a_param('loggedout_param_names') },
176             is_page => sub { return 1 },
177             handle_divert => sub { return 0 },
178             do_redirect => \&do_redirect_cgi, # this hook is allowed to throw
179             cookie_path => "/",
180             get_cookie_domain => \&get_cookie_domain,
181             encrypted_only => 0,
182             gen_start_html => sub { $_[0]->start_html($_[2]); },
183             gen_end_html => sub { $_[0]->end_html(); },
184             gen_login_form => \&gen_plain_login_form,
185             gen_login_link => \&gen_plain_login_link,
186             gettext => sub { gettext($_[2]); },
187             };
188         },
189         Dbh => undef,
190     };
191     my ($k,$v);
192     while (($k,$v,@_) = @_) {
193         die "unknown setting $k" unless exists $s->{S}{$k};
194         $s->{S}{$k} = $v;
195     }
196     bless $s, $class;
197     $s->_dbopen();
198     return $s;
199 }
200
201 sub _dbopen ($) {
202     my ($s) = @_;
203     my $dbh = $s->{Dbh};
204     return $dbh if $dbh; 
205
206     $s->{S}{assocdb_dsn} ||= "dbi:SQLite:dbname=$s->{S}{assocdb_path}";
207
208     my $u = umask 077;
209     $dbh = DBI->open($s->{S}{assocdb_dsn}, $s->{S}{assocdb_user}, 
210                      $s->{S}{assocdb_password}, { 
211                          AutoCommit => 0, RaiseError => 1,
212                      });
213     die "${assocdb_dsn} $! ?" unless $dbh;
214     $s->{Dbh} = $dbh;
215
216     $dbh->do("BEGIN");
217
218     eval {
219         $dbh->do("CREATE TABLE $s->{S}{assocdb_table} (".
220                  " associdh VARCHAR PRIMARY KEY,".
221                  " username VARCHAR,".
222                  " last INTEGER NOT NULL"
223                  ")");
224     };
225     return $dbh;
226 }
227
228 #---------- request object methods ----------
229
230 sub new_request {
231     my ($classbase, $cgi, @extra) = @_;
232     if (!ref $classbase) {
233         $classbase = $classbase->new_verifier(@extra);
234     } else {
235         die if @extra;
236     }
237     my $r = {
238         V => $classbase,
239         S => $classbase->{S},
240         Dbh => $classbase->{Dbh},
241         Cgi => $cgi,
242     };
243     bless $r, ref $classbase;
244 }
245
246 sub _ch ($$@) { # calls an application hook
247     my ($r,$methname, @args) = @_;
248     my $methfunc = $r->{S}{$methname};
249     return $methfunc->($r->{Cgi}, $r, @args);
250 }
251
252 sub _rp ($$@) {
253     my ($r,$pnvb) = @_;
254     my $pn = $r->{S}{$pnvb};
255     my $p = scalar $r->_ch('get_param',$pn)
256 }
257
258 sub _gt ($$) { my ($r, $t) = @_; return $r->_ch('gettext')($t); }
259 sub _print ($$) { my ($r, @t) = @_; return $r->_ch('print')(join '', @t); }
260
261 # pages/param-sets are
262 #   n normal non-mutating page
263 #   r retrieval of information for JS, non-mutating
264 #   m mutating page
265 #   u update of information by JS, mutating
266 #   i login
267 #   o logout
268 #   O "you have just logged out" page load
269
270 # in cook and par,
271 #    a, aN     anything including -
272 #    t, tN     temporary value (in our db, no logged in user yet)
273 #    y, yN     value corresponds to logged-in user
274 #    n, nN     value not in our db
275 #    x, xN     t or y
276 #    -         no value supplied
277 # if N differs the case applies only when the two values differ
278 # (eg,   a1 y2   does not apply when the logged-in value is supplied twice)
279
280 # "stale session" means request originates from a page from a login
281 # session which has been revoked (eg by logout); "cleared session"
282 # means request originates from a browser which has a different (or
283 # no) cookie.
284
285     # Case analysis, cookie mode, app promises re mutate:
286     # cook parm meth form
287     #                      
288     #  any -   POST  nrmuoi   bug or attack, fail
289     #  any -   GET    rmuoi   bug or attack, fail
290     #  any any GET     muoi   bug or attack, fail
291     #  any t   any   nrmu     bug or attack, fail
292     #
293     #  -   -   GET         O  "just logged out" page
294     #  (any other)         O  bug or attack, fail
295     #
296     #  a1  a2  POST      o    logout
297     #                           if a1 is valid, revoke it
298     #                           if a2 is valid, revoke it
299     #                           delete cookie
300     #                           redirect to "just logged out" page
301     #                             (which contains link to login form)
302     #
303     #  -   t   POST       i   complain about cookies being disabled
304     #                           (with link to login form)
305     #
306     #  any n   POST       i   complain about stale login form
307     #                           show new login form
308     #
309     #  x1  t2  POST       i   login (or switch user)
310     #                           if bad
311     #                             show new login form
312     #                           if good
313     #                             revoke x1 if it was valid and !=t2
314     #                             upgrade t2 to y2 in our db (setting username)
315     #                             set cookie to t2
316     #                             redirect to GET of remaining params
317     #
318     #  t1  a2  ANY   nrmu     treat as  - a2 ANY
319     #
320     #  y   -   GET   n        cross-site link
321     #                           show data
322     #
323     #  y   y   GET   nr       fine, show page or send data
324     #  y   y   POST  nrmu     mutation is OK, do operation
325     #
326     #  y1  y2  GET   nr       request from stale page
327     #                           do not revoke y2 as not RESTful
328     #                           treat as   y1 n GET
329     #
330     #  y1  y2  POST  nrmu     request from stale page
331     #                           revoke y2
332     #                           treat as   y1 n POST
333     #
334     #  y   n   GET   n        intra-site link from stale page,
335     #                           treat as cross-site link, show data
336     #
337     #  y   n   POST  n m      intra-site form submission from stale page
338     #                           show "session interrupted"
339     #                           with link to main data page
340     #
341     #  y   n   GET    r       intra-site request from stale page
342     #                           fail
343     #
344     #  y   n   POST   r u     intra-site request from stale page
345     #                           fail
346     #
347     #  -/n y2  GET   nr       intra-site link from cleared session
348     #                           do not revoke y2 as not RESTful
349     #                           treat as   -/n n GET
350     #
351     #  -/n y2  POST  nrmu     request from cleared session
352     #                           revoke y2
353     #                           treat as   -/n n POST
354     #
355     #  -/n n   GET   n        cross-site link but user not logged in
356     #                           show login form with redirect to orig params
357     #
358     #  -/n n   GET    rmu     user not logged in
359     #                           fail
360     #
361     #  -/n n   POST  n m      user not logged in
362     #                           show login form
363     #
364     #  -/n n   POST   r u     user not logged in
365     #                           fail
366
367 sub _check_divert_core ($) {
368     my ($r) = @_;
369
370     my $meth = $r->_ch('get_method');
371     my $cookv = $r->_ch('get_cookie');
372     my $parmv = $r->_rp('assoc_param_name');
373
374     my ($cookt,$cooku) = $r->_db_lookup($cookv);
375     my $parmt = $r->_db_lookup($parmv);
376
377     if ($r->_ch('is_logout')) {
378         $r->_must_be_post();
379         die unless $parmt;
380         $r->_db_revoke($cookv);
381         $r->_db_revoke($parmv);
382         return ({ Kind => 'REDIRECT-LOGGEDOUT',
383                   Message => "Logging out...",
384                   CookieVal => '',
385                   Params => { } });
386     }
387     if ($r->_ch('is_loggedout')) {
388         die unless $meth eq 'GET';
389         die unless $cookt;
390         die unless $parmt;
391         return ({ Kind => 'SMALLPAGE-LOGGEDOUT',
392                   Message => "You have been logged out.",
393                   CookieVal => '',
394                   Params => { } });
395     }
396     if ($r->_ch('is_login')) {
397         $r->_must_be_post();
398         return ({ Kind => 'LOGIN-STALE',
399                   Message => "Stale session; you need to log in again.",
400                   CookieVal => $r->_fresh_cookie(),
401                   Params => { } })
402             if $parmt eq 'n';
403         die unless $parmt eq 't' || $parmt eq 'y';
404         return ({ Kind => 'SMALLPAGE-NOCOOKIE',
405                   Message => "You do not seem to have cookies enabled.  ".
406                       "You must enable cookies as we use them for login.",
407                   CookieVal => $r->_fresh_cookie(),
408                   Params => $r->_chain_params() })
409             if !$cookt && $parmt eq 't';
410         my $username = $r->_ch('login_ok');
411         return ({ Kind => 'LOGIN-BAD',
412                   Message => "Incorrect username/password.",
413                   CookieVal => $cookv,
414                   Params => $r->_chain_params() })
415             unless defined $username && length $username;
416         $r->_db_revoke($cookv) 
417             if defined $cookv && !(defined $parmv && $cookv eq $parmv);
418         $r->_db_record_login_ok($parmv,$username);
419         return ({ Kind => 'REDIRECT-LOGGEDIN',
420                   Message => "Logging in...",
421                   CookieVal => $parmv,
422                   Params => $r->_chain_params() });
423     }
424     if ($cookt eq 't') {
425         $cookt = '';
426     }
427     die if $parmt eq 't';
428
429     if ($cookt eq 'y' && $parmt eq 'y' && $cookv ne $parmv) {
430         $r->_db_revoke($parmv) if $meth eq 'POST';
431         $parmt = 'n';
432     }
433
434     if ($cookt ne 'y') {
435         die unless !$cookt || $cookt eq 'n';
436         die unless !$parmt || $parmt eq 'n' || $parmt eq 'y';
437         if ($meth eq 'GET') {
438             return ({ Kind => 'LOGIN-INCOMINGLINK',
439                       Message => "You need to log in again.",
440                       CookieVal => $parmv,
441                       Params => $r->_chain_params() });
442         } else {
443             return ((Kind => 'LOGIN-FRESH',
444                      Message => "You need to log in again.",
445                      CookieVal => $parmv,
446                      Params => { });
447         }
448     }
449
450     if (!$r->{S}{promise_check_mutate}) {
451         if ($meth ne 'POST') {
452             return ({ Kind => 'MAINPAGEONLY',
453                       Message => 'Entering via cross-site link.',
454                       CookieVal => $cookv,
455                       Params => { } });
456             # NB caller must then ignore params & path!
457             # if this is too hard they can spit out a small form
458             # with a "click to continue"
459         }
460     }
461
462     die unless $cookt eq 'y';
463     die unless $parmt eq 'y';
464     die unless $cookv eq $parmv;
465     $r->{Assoc} = $cookv;
466     $r->{UserOK} = $cooku;
467     return undef;
468 }
469
470 sub _chain_params ($) {
471     my ($r) = @_;
472     my %p = %{ $r->_ch('get_params') };
473     foreach my $pncn (keys %{ $r->{S} }) {
474         if ($pncn =~ m/_param_name$/) {
475             my $name = $r->{S}{$pncn};
476             die "$pncn ?" if ref $name;
477             $names = [ $name ];
478         } elsif ($pncn =~ m/_param_names$/) {
479             $names = $r->{S}{$pncn};
480         } else {
481             next;
482         }
483         foreach my $param (@$names) {
484             delete $p{$name};
485         }
486     }
487     return \%p;
488 }
489
490 sub _db_lookup ($$) {
491     # returns ($t,$username)
492     # where $t is one of "t" "y" "n", or "" (for -)
493     my ($r,$v) = @_;
494
495     my $dbh = $r->{Dbh};
496
497     my ($nusername, $nlast) =
498         $dbh->selectrow_array("SELECT username, last".
499                               " FROM $r->{S}{assocdb_table}".
500                               " WHERE associd = ?", {}, $nassoc);
501     return ('') unless defined $nusername;
502
503     my $timeout = $r->{S}{login_timeout};
504     return ('n') unless !defined $timeout || time <= $nlast + $timeout;
505
506     return ('t') unless defined $nusername;
507
508     # hooray
509     return ('y', $nusername);
510 }
511
512 sub _db_revoke ($$) {
513     # revokes $v if it's valid; no-op if it's not
514     my ($r,$v) = @_;
515
516     my $dbh = $r->{Dbh};
517
518     $dbh->do("DELETE FROM $r->{S}{assocdb_table}".
519              " WHERE associd = ?", {}, $v);
520 }
521
522 sub _db_record_login_ok ($$$) {
523     my ($r,$v,$user) = @_;
524     $r->_db_revoke($v);
525     $dbh->do("INSERT INTO $r->{S}{assocdb_table}".
526              " (associd, username, last) VALUES (?,?,?)", {},
527              $v, $user, time);
528 }
529
530 sub check_divert ($) {
531     my ($r) = @_;
532     my $divert;
533     if (exists $r->{Divert}) {
534         return $r->{Divert};
535     }
536     $dbh->do("BEGIN");
537     if (!eval {
538         $divert = $r->_check_divert_core();
539         1;
540     }) {
541         $dbh->do("ABORT");
542         die $@;
543     }
544     $r->{Divert} = $divert;
545     $dbh->do("COMMIT");
546     return $divert;
547 }
548
549 sub get_divert ($) {
550     my ($r) = @_;
551     die "unchecked" unless exists $r->{Divert};
552     return $r->{Divert};
553 }
554
555 sub get_username ($) {
556     my ($r) = @_;
557     my $divert = $r->get_divert();
558     return undef if $divert;
559     return $r->{UserOK};
560 }
561
562 sub url_with_query_params ($$) {
563     my ($r, $params) = @_;
564     my $uri = URI->new($r->_ch('get_url'));
565     $uri->query_form(flatten_params($params));
566     return $uri->as_string();
567 }
568
569 sub check_ok ($) {
570     my ($r) = @_;
571
572     my ($divert) = $authreq->check_divert();
573     return 1 if $divert;
574
575     my $handled = $r->_ch('handle_divert')($divert);
576     return 0 if $handled;
577
578     my $kind = $divert->{Kind};
579     my $cookieval = $divert->{CookieVal};
580     my $params = $divert->{Params};
581
582     if ($kind =~ m/^REDIRECT-/) {
583         # for redirects, we honour stored NextParams and SetCookie,
584         # as we would for non-divert
585         if ($divert_kind eq 'REDIRECT-LOGGEDOUT') {
586             $params{$r->{S}{loggedout_param_names}[0]} = 1;
587         } elsif ($divert_kind eq 'REDIRECT-LOGOUT') {
588             $params{$r->{S}{logout_param_names}[0]} = 1;
589         } elsif ($divert_kind eq 'REDIRECT-LOGGEDIN') {
590         } else {
591             die;
592         }
593         my $new_url = $r->url_with_query_params($params);
594         my $cookie = $r->construct_cookie($r, $cookieval);
595         $r->_ch('do_redirect')($new_url, $cookie);
596         return 0;
597     }
598
599     my ($title, @body);
600     if ($kind =~ m/^LOGIN-/) {
601         $title = $r->_gt('Login');
602         push @body, $r->_gt($divert->{Message});
603         push @body, $r->_ch('gen_login_form', $params);
604     } elsif ($kind =~ m/^SMALLPAGE-/) {
605         $title = $r->_gt('Not logged in');
606         push @body, $r->_gt($divert->{Message});
607         push @body, $r->_ch('gen_login_link');
608     } else {
609         die $kind;
610     }
611
612     $r->_print($r->_ch('start_html')($title),
613                @body,
614                $r->_ch('end_html'));
615     return 0;
616 }
617
618 sub _random ($$) {
619     my ($r, $bytes) = @_;
620     my $v = $r->{V};
621     if (!$v->{RandomHandle}) {
622         my $rsp = $r->{S}{random_source};
623         my $rsf = new IO::File $rsp, '<' or die "$rsp $!";
624         $v->{RandomHandle} = $rsf;
625     }
626     my $bin;
627     $!=0;
628     read($rsf,$bin,$bytes) == $bytes or die "$rsp $!";
629     close $rsf;
630     return unpack "H*", $bin;
631 }
632
633 sub _fresh_cookie ($) {
634     my ($r) = @_;
635     my $bytes = ($r->{S}{associdlen} + 7) >> 3;
636     return $r->_random($bytes);
637 }
638
639 sub check_mutate ($) {
640     my ($r) = @_;
641     die "unchecked" unless exists $r->{Divert};
642     die if $r->{Divert};
643     my $meth = $r->_ch('get_method');
644     die "mutating non-POST" if $meth ne 'POST';
645 }
646
647 #---------- output ----------
648
649 sub secret_val ($) {
650     my ($r) = @_;
651     $r->check();
652     return defined $r->{Assoc} ? $r->{Assoc} : '';
653 }
654
655 sub secret_hidden_html ($) {
656     my ($r) = @_;
657     return $r->{Cgi}->hidden(-name => $r->{S}{assoc_param_name},
658                              -default => $r->secret_val());
659 }
660
661 sub secret_cookie ($) {
662     my ($r) = @_;
663     return $r->construct_cookie($r->secret_val());
664 }
665
666 __END__
667
668 =head1 NAME
669
670 CGI::Auth::Hybrid - web authentication optionally using cookies
671
672 =head1 SYNOPSYS
673
674  my $verifier = CGI::Auth::Hybrid->new_verifier(setting => value,...);
675  my $authreq = $verifier->new_request($cgi_request_object);
676
677  my $authreq = CGI::Auth::Hybrid->new_request($cgi_request_object,
678                                               setting => value,...);
679
680 =head1 USAGE PATTERN FOR SIMPLE APPLICATIONS
681
682  $authreq->check_ok() or return;
683
684  blah blah blah
685  $authreq->check_mutate();
686  blah blah blah
687
688 =head1 USAGE PATTERN FOR FANCY APPLICATIONS
689
690  my $divert_kind = $authreq->check_divert();
691  if ($divert_kind) {
692      if ($divert_kind eq 'LOGGEDOUT') {
693          print "goodbye you are now logged out" and quit
694      } elsif ($divert_kind eq 'NOCOOKIES') {
695          print "you need cookies" and quit
696      ... etc.
697      }
698  }
699
700  blah blah blah
701  $authreq->check_mutate();
702  blah blah blah