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