File Coverage

lib/Mozilla/Persona/Server.pm
Criterion Covered Total %
statement 48 151 31.7
branch 0 48 0.0
condition 0 13 0.0
subroutine 16 34 47.0
pod 14 15 93.3
total 78 261 29.8


line stmt bran cond sub pod time code
1             # Copyrights 2012 by [Mark Overmeer].
2             # For other contributors see ChangeLog.
3             # See the manual pages for details on the licensing terms.
4             # Pod stripped from pm file by OODoc 2.00.
5 1     1   4 use warnings;
  1         16  
  1         25  
6 1     1   3 use strict;
  1         2  
  1         37  
7              
8             package Mozilla::Persona::Server;
9 1     1   4 use vars '$VERSION';
  1         2  
  1         40  
10             $VERSION = '0.12';
11              
12 1     1   4 use open 'utf8';
  1         1  
  1         4  
13              
14 1     1   53 use Log::Report qw/persona/;
  1         1  
  1         6  
15              
16 1     1   242 use Crypt::OpenSSL::Bignum ();
  1         1  
  1         21  
17 1     1   5 use Crypt::OpenSSL::RSA ();
  1         1  
  1         70  
18              
19 1     1   7 use CGI::Session ();
  1         2  
  1         18  
20 1     1   5 use JSON qw(decode_json encode_json);
  1         1  
  1         35  
21 1     1   138 use File::Slurp qw/read_file write_file/;
  1         2  
  1         94  
22 1     1   5 use MIME::Base64 qw(encode_base64url);
  1         2  
  1         131  
23 1     1   5 use Time::HiRes qw(time);
  1         2  
  1         11  
24 1     1   90 use List::Util qw(first);
  1         1  
  1         62  
25 1     1   4 use File::Basename qw(dirname);
  1         1  
  1         35  
26 1     1   4 use File::Spec ();
  1         2  
  1         25  
27              
28 1     1   4 use constant MIME_JSON => 'application/json; charset=UTF-8';
  1         1  
  1         2229  
29              
30              
31 0     0 1   sub new(%) { my $class = shift; (bless {}, $class)->init({@_}) }
  0            
32             sub init($)
33 0     0 0   { my ($self, $args) = @_;
34 0 0         $self->{MP_pem_fn} = $args->{private_pem} or panic;
35 0 0         $self->{MP_cookie} = $args->{cookie_name} or panic;
36 0 0         $self->{MP_domain} = $args->{domain} or panic;
37 0   0       $self->{MP_aliparms} = $args->{aliases}
38             || { class => 'Mozilla::Persona::Aliases' };
39 0   0       $self->{MP_valparms} = $args->{validator}
40             || { class => 'Mozilla::Persona::Validate::Table'
41             , pwfile => '/etc/persona/passwords'
42             };
43 0           $self;
44             }
45              
46              
47             sub fromConfig($)
48 0     0 1   { my ($class, $fn) = (shift, shift);
49 0           my $config = decode_json read_file $fn;
50 0           $class->new(%$config, @_);
51             }
52              
53             #-----------------
54              
55 0     0 1   sub cookie() {shift->{MP_cookie}}
56 0     0 1   sub domain() {shift->{MP_domain}}
57              
58              
59             sub aliases()
60 0     0 1   { my $self = shift;
61 0 0         return $self->{MP_aliases}
62             if $self->{MP_aliases};
63              
64 0   0       my $config = $self->{MP_aliparms} || {};
65              
66             # load alias expansion plugin
67 0 0         my $class = delete $config->{class} or panic;
68 0 0         eval "require $class"; panic $@ if $@;
  0            
69              
70 0           $self->{MP_aliases} = $class->new(%$config);
71             }
72              
73              
74             sub validator()
75 0     0 1   { my $self = shift;
76 0 0         return $self->{MP_validator}
77             if $self->{MP_validator};
78              
79 0   0       my $config = $self->{MP_valparms} || {};
80              
81             # load username/password validator
82 0 0         my $class = delete $config->{class} or panic;
83 0 0         eval "require $class"; panic $@ if $@;
  0            
84              
85 0           $self->{MP_validator} = $class->new(%$config);
86             }
87              
88              
89             sub privatePEM()
90 0     0 1   { my $self = shift;
91              
92 0           my $pem = read_file $self->{MP_pem_fn};
93 0           my $key = Crypt::OpenSSL::RSA->new_private_key($pem);
94 0           $key->use_pkcs1_padding;
95 0           $key->use_sha256_hash;
96 0           $key;
97             }
98              
99             #------------------------
100              
101             sub getSession($)
102 0     0 1   { my ($self, $cgi) = @_;
103 0 0         my $cookie = $cgi->cookie($self->cookie)
104             or error __x"no session cookie";
105              
106 0 0         my $session = CGI::Session->new('driver:File', $cookie)
107             or error __x"invalid session cookie";
108              
109 0           $session;
110             }
111              
112              
113             sub _sign($$$)
114 0     0     { my ($self, $client_pubkey, $email, $duration) = @_;
115              
116             # NB. Treating the jwcrypto code as the spec here.
117 0           my $issued_at = int(1000*time);
118              
119 0           my %cert =
120             ( iss => $self->domain
121             , exp => $issued_at + 1000*$duration
122             , iat => $issued_at
123             , "public-key" => $client_pubkey
124             , principal => { email => $email }
125             );
126              
127 0           my %header =
128             ( typ => 'JWT'
129             , alg => 'RS256'
130             );
131              
132 0           my $header_enc = encode_base64url encode_json \%header;
133 0           my $cert_enc = encode_base64url encode_json \%cert;
134              
135 0 0         my $key = $self->privatePEM or return;
136 0           my $sig_enc = encode_base64url $key->sign("$header_enc.$cert_enc");
137              
138 0           "$header_enc.$cert_enc.$sig_enc";
139             }
140              
141             sub actionSign($)
142 0     0 1   { my ($self, $cgi) = @_;
143              
144 0           my $session = $self->getSession($cgi);
145 0           my $user = $session->param('user');
146              
147 0           print $cgi->header(-content_type => MIME_JSON);
148              
149 0 0         my $user_pubkey = $cgi->param('pubkey')
150             or error __x"nothing to sign for {user}", user => $user;
151              
152 0   0       my $duration = $cgi->param('duration') || 24*3600;
153              
154 0 0         my $email = $cgi->param('email')
155             or error __x"no email address to sign for {user}", user => $user;
156              
157 0 0         $self->isAliasFor($user, $email)
158             or error __x"user {username} is not authorized to use {email}"
159             , username => $user, email => $email;
160            
161 0           trace "signed $user $email";
162              
163 0           my $sig = $self->_sign(decode_json($user_pubkey), $email, $duration);
164 0           print encode_json({signature => $sig}), "\n";
165             }
166              
167              
168             sub actionLogin($)
169 0     0 1   { my ($self, $cgi) = @_;
170              
171 0 0         my $email = $cgi->param('email')
172             or error __x"no email address provided";
173              
174 0 0         my $password = $cgi->param('password')
175             or error __x"no password provided for {email}", email => $email;
176              
177 0           my $validator = $self->validator;
178 0           my @aliases = $self->aliases->for($email);
179 0     0     my $user = first {$validator->isValid($_, $password)} @aliases;
  0            
180              
181 0 0         defined $user
182             or error __x"authentication for {email} failed (aliases {aliases})"
183             , email => $email, aliases => \@aliases;
184              
185 0           trace "authenticated $user";
186              
187 0           my $session;
188 0 0         if(my $cookie = $cgi->cookie($self->cookie))
189 0           { $session = CGI::Session->new("driver:File", $cookie);
190             }
191              
192 0 0         if($session)
193             { # session restored
194 0           print $cgi->header(-content_type => MIME_JSON);
195             }
196             else
197             { # new session, new cookie
198 0           $session = CGI::Session->new("driver:File", undef);
199 0           my $cookie = $cgi->cookie
200             ( -name => $self->cookie
201             , -value => $session->id
202             , -expires => '+1d'
203             , -secure => 1
204             , -httponly => 1
205             , -domain => $self->domain
206             );
207 0           print $cgi->header(-content_type => MIME_JSON, -cookie => $cookie);
208             }
209              
210 0           $session->param(user => $user);
211 0           print encode_json({user => $user}), "\n";
212             }
213              
214              
215             sub actionIsLoggedIn($)
216 0     0 1   { my ($self, $cgi) = @_;
217              
218 0           my $is_logged_in = 0;
219 0 0         if(my $cookie = $cgi->cookie($self->cookie))
220 0           { my $email = $cgi->param('email');
221 0 0         if(my $session = CGI::Session->new('driver:File', $cookie))
222 0           { my $user = $session->param('user');
223 0   0       $is_logged_in = $email && $user && $self->isAliasFor($user, $email);
224 0 0         trace "$email $user is ".($is_logged_in ? '' : ' not')."logged in";
225             }
226             else
227 0           { trace "no session for $email";
228             }
229             }
230             else
231 0           { trace "not logged in";
232             }
233              
234 0           print $cgi->header(-content_type => MIME_JSON)
235             , encode_json({logged_in_p => $is_logged_in})
236             , "\n";
237             }
238              
239              
240             sub actionPing($)
241 0     0 1   { my ($self, $cgi) = @_;
242 0           print $cgi->header(-content_type => 'text/plain'), "PONG\n";
243             }
244              
245             #------------------------------
246              
247             sub isAliasFor($$)
248 0     0 1   { my ($self, $user, $email) = @_;
249              
250 0           my @aliases = $self->aliases->for($email);
251             @aliases
252 0 0         or error __x"user {user} for {email} not found"
253             , user => $user, email => $email;
254              
255 0           my $lc_user = lc $user;
256 0     0     first {$lc_user eq lc $_} @aliases;
  0            
257             }
258              
259              
260             sub writeConfig($)
261 0     0 1   { my ($self, $fn) = @_;
262 0           my %data;
263            
264 0           @data{ qw/private_pem cookie_name domain aliases validator/ }
265 0           = @{$self}{ qw/MP_pem_fn MP_cookie MP_domain MP_aliparms MP_valparms/ };
266              
267             # MO: it's a pity, but there is no way to add comments to json.
268 0           $data{generated_by} = "$0 $main::VERSION";
269 0           $data{generated_on} = localtime();
270              
271 0           write_file $fn, JSON->new->utf8->pretty(1)->encode(\%data);
272             }
273              
274             1;