File Coverage

blib/lib/Captive/Portal/Role/Session.pm
Criterion Covered Total %
statement 106 140 75.7
branch 30 68 44.1
condition n/a
subroutine 20 25 80.0
pod 10 10 100.0
total 166 243 68.3


line stmt bran cond sub pod time code
1             package Captive::Portal::Role::Session;
2              
3 6     6   32292 use strict;
  6         18  
  6         255  
4 6     6   35 use warnings;
  6         12  
  6         415  
5              
6             =head1 NAME
7              
8             Captive::Portal::Role::Session - session methods for Captive::Portal
9              
10             =cut
11              
12             our $VERSION = '4.10';
13              
14 6     6   37 use Log::Log4perl qw(:easy);
  6         13  
  6         67  
15 6     6   5116 use JSON qw();
  6         21  
  6         147  
16 6     6   33 use Try::Tiny;
  6         9  
  6         536  
17 6     6   37 use Digest::MD5 qw(md5_hex);
  6         14  
  6         345  
18 6     6   4335 use Captive::Portal::LockHandle;
  6         22  
  6         78  
19              
20 6     6   9010 use Role::Basic;
  6         17  
  6         65  
21             requires qw(
22             cfg
23             spawn_cmd
24             normalize_ip
25             find_mac
26             );
27              
28             # Role::Basic exports ALL subroutines, there is currently no other way to
29             # prevent exporting private methods, sigh
30             #
31             my $_init_session = sub {
32             my ( $self, $ip, $mac ) = @_;
33              
34             my $new_session = {
35             STATE => 'init',
36             IP => $ip,
37             MAC => $mac,
38             };
39              
40             return $new_session;
41             };
42              
43             =head1 DESCRIPTION
44              
45             IP addresses of clients must be unique. They are determined by the HTTP-Parameter I. The corresponding MAC-address is determined from the ARP-table. If there are duplicate IP-addresses for different MAC-addresses something bad is happening (ARP-spoofing, ...). Captive::Portal warns on duplicate IP-addresses.
46              
47             Active sessions have corresponding IP/MAC entries in the B.
48              
49             Session state is recorded on disc under the $SESSIONS_DIR. The session state is JSON encoded.
50              
51             Example: active session
52              
53             {
54             "STATE" : "active",
55             "START_TIME" : 1317106093,
56             "STOP_TIME" : "",
57             "USERNAME" : "foo",
58             "IP" : "134.60.239.90",
59             "MAC" : "F0:F4:69:17:89:DE",
60             "USER_AGENT" : "Mozilla/5.0 ... Safari/534.50",
61             "COOKIE" : "202ceeee8c0ec85869dbac19c57c3c5e"
62             }
63              
64             =head1 ROLES
65              
66             All roles throw exceptions on error.
67              
68             =over 4
69              
70             =item $capo->get_current_session()
71              
72             Returns the current- or a new initialized session-hash for this HTTP-Client.
73              
74             =cut
75              
76             sub get_current_session {
77 3     3 1 6 my $self = shift;
78              
79 3         11 my $query = $self->{CTX}{QUERY};
80              
81 3         7 my ( $ip, $mac_from_arptable );
82 3 50       71 $ip = $query->remote_addr
83             or LOGDIE "Couldn't fetch client IP from HTTP query\n";
84              
85 3         42 $ip = $self->normalize_ip($ip);
86              
87 3         20 DEBUG "try to find MAC addr for ip '$ip'";
88 3         43 $mac_from_arptable = $self->find_mac($ip);
89              
90 3 50       106 unless ($mac_from_arptable) {
91 0         0 WARN "request from '$ip', no MAC address found";
92 0         0 return;
93             }
94              
95 3         19 DEBUG("use mac '$mac_from_arptable' for ip '$ip'");
96              
97             # fetch session data, non-blocking shared lock
98             # don't fill the lock queue with readers
99              
100 3         20 my ( $session, $error );
101             try {
102 3     3   126 my $lock_handle = $self->get_session_lock_handle(
103             key => $ip,
104             try => 10,
105             blocking => 0,
106             shared => 1,
107             );
108              
109 3         15 $session = $self->read_session_handle($lock_handle);
110             }
111 3     0   38 catch { $error = $_ };
  0         0  
112              
113 3 50       206 die "$error\n" if $error;
114              
115 3 100       10 unless ($session) {
116              
117 2         12 DEBUG "initialize new session for $ip/$mac_from_arptable";
118 2         21 $session = $self->$_init_session( $ip, $mac_from_arptable );
119              
120 2         12 return $session;
121             }
122              
123             # session for IP exists already, check for same MAC address
124 1 50       6 if ( $mac_from_arptable eq $session->{MAC} ) {
125              
126 1         6 DEBUG "return current session for $ip";
127 1         12 return $session;
128              
129             }
130              
131             ###############################
132             # handle duplicate IP address
133             #
134              
135 0         0 my $mac = $session->{MAC};
136 0         0 my $user = $session->{USERNAME};
137              
138 0         0 DEBUG "old session $user/$ip/$mac with different MAC on disk cache";
139              
140             #
141             # is old session still marked as active?
142             # die with error page
143             #
144 0 0       0 if ( $session->{STATE} eq 'active' ) {
145 0         0 WARN "$user/$ip/$mac -> duplicate IP from $mac_from_arptable";
146 0         0 die "Your IP address is duplicate: $ip\n";
147             }
148              
149             ########
150             # old session is already idle, init new session,
151             # disk cache is rewritten after login
152             #
153 0         0 DEBUG "initialize new session for $ip/$mac_from_arptable";
154 0         0 $session = $self->$_init_session( $ip, $mac_from_arptable );
155              
156 0         0 return $session;
157             }
158              
159             =item $capo->open_sessions_dir()
160              
161             Open/create the sessions directory defined in the config file.
162              
163             =cut
164              
165             sub open_sessions_dir {
166 5     5 1 14 my $self = shift;
167              
168 5         25 my $sessions_dir = $self->cfg->{SESSIONS_DIR};
169              
170 5 50       230 unless ( -d $sessions_dir ) {
171              
172 0         0 DEBUG("create sessions directory: $sessions_dir");
173 0         0 my @cmd = ( 'mkdir', '-p', $sessions_dir );
174              
175 0         0 my $error;
176             try {
177 0     0   0 $self->spawn_cmd(@cmd);
178             }
179             catch {
180 0     0   0 $error = $_;
181 0         0 };
182              
183 0 0       0 LOGDIE $error if $error;
184             }
185              
186             # the sessions directory must be writable
187              
188 5 50       89 LOGDIE "missing write permissions on '$sessions_dir'"
189             unless -w $sessions_dir;
190              
191 5         21 return 1;
192             }
193              
194             =item $capo->clear_sessions_from_disk()
195              
196             Unlink all session files from disk.
197              
198             =cut
199              
200             sub clear_sessions_from_disk {
201 3     3 1 35 my $self = shift;
202              
203 3         13 DEBUG 'clearing all sessions';
204              
205 3         31 foreach my $key ( $self->list_sessions_from_disk ) {
206              
207 52         71 my $error;
208             try {
209 52     52   1471 my $lock_handle = $self->get_session_lock_handle(
210             key => $key,
211             blocking => 0,
212             shared => 0, # EXCL
213             try => 10,
214             );
215              
216 52         216 DEBUG "delete session: $key";
217 52         420 $self->delete_session_from_disk($key);
218              
219             }
220 52     0   413 catch { $error = $_ };
  0         0  
221 52 50       2750 LOGDIE "$error\n" if $error;
222             }
223              
224 3         25 return 1;
225             }
226              
227             =item $capo->list_sessions_from_disk()
228              
229             Return a list of all session filenames in sessions dir.
230              
231             =cut
232              
233             sub list_sessions_from_disk {
234 9     9 1 48 my $self = shift;
235              
236 9         37 my $sessions_dir = $self->cfg->{SESSIONS_DIR};
237              
238 9 50       494 opendir( my $dir_handle, $sessions_dir )
239             or LOGDIE "Couldn't opendir $sessions_dir: $!";
240              
241             # session filenames are ip addresses
242 132         405 my @sessions =
243 9         368 grep { m/\A \d{1,3} \. \d{1,3} \. \d{1,3} \. \d{1,3} \Z/x }
244             readdir $dir_handle;
245              
246 9         360 return @sessions;
247             }
248              
249             =item $capo->get_session_lock_handle(%named_params)
250              
251             Return a filehandle to the clients session file with the requested lock assigned. There is no unlock required, after destroying the filehandle the file is closed and the lock released.
252              
253             Named parameters:
254              
255             key => ip address of session
256             shared => shared lock, defaults to exclusive lock
257             blocking => blocking lock request, defaults to blocking
258             try => number of retries in nonblocking mode, defaults to 1 retry
259             timeout => timeout in blocking mode, defaults to 1s
260              
261             =cut
262              
263             sub get_session_lock_handle {
264 111     111 1 1978 my $self = shift;
265 111         349 my %opts = @_;
266              
267 111 50       285 LOGDIE "missing param 'key'" unless exists $opts{key};
268              
269 111         310 $opts{file} = $self->cfg->{SESSIONS_DIR} . "/$opts{key}";
270              
271             # just a wrapper for:
272             #
273 111         1243 return Captive::Portal::LockHandle->new(%opts);
274             }
275              
276             =item $capo->read_session_handle($lock_handle)
277              
278             Read the session file for $lock_handle and decode the JSON format into a hashref.
279              
280             =cut
281              
282             sub read_session_handle {
283 9     9 1 22 my $self = shift;
284 9 50       32 my $fh = shift
285             or LOGDIE "missing param 'file_handle'";
286              
287 9         31 DEBUG "read_session_handle";
288              
289 9 50       200 seek( $fh, 0, 0 ) or LOGDIE "Couldn't rewind session file: $!";
290              
291 9         41 local $/;
292 9         290 my $slurp = <$fh>;
293              
294 9 50       36 unless ( defined $slurp ) {
295 0         0 ERROR "Couldn't slurp session file: $!";
296 0         0 return;
297             }
298              
299             # emtpy file
300 9 100       51 return if $slurp eq '';
301              
302 6         12 my ( $session, $error );
303 6     6   58 try { $session = JSON->new->decode($slurp) } catch { $error = $_ };
  6         333  
  0         0  
304              
305 6 50       108 if ($error) {
306              
307             # JSON exception to logfile
308 0         0 ERROR $error;
309              
310 0         0 return;
311             }
312              
313 6         38 return $session;
314             }
315              
316             =item $capo->write_session_handle($lock_handle, $session)
317              
318             Encode the session hashref into JSON and write the session file belonging to $lock_handle.
319              
320             =cut
321              
322             sub write_session_handle {
323 4     4 1 10 my $self = shift;
324              
325 4 50       35 my $fh = shift
326             or LOGDIE "missing param 'file_handle'";
327              
328 4 50       15 my $session = shift
329             or LOGDIE "missing param 'session'";
330              
331 4         16 DEBUG "write_session_handle";
332              
333 4 50       140 seek( $fh, 0, 0 ) or LOGDIE "Couldn't rewind session file: $!";
334 4 50       277 truncate( $fh, 0 ) or LOGDIE "Couldn't truncate session file: $!";
335              
336 4 50       222 print $fh JSON->new->pretty->encode($session)
337             or LOGDIE "Couldn't write session: $!";
338             }
339              
340             =item $capo->delete_session_from_disk($key)
341              
342             Unlink session file from disk.
343              
344             =cut
345              
346             sub delete_session_from_disk {
347 52     52 1 71 my $self = shift;
348              
349 52 50       116 my $key = shift
350             or LOGDIE "missing param 'session key'";
351              
352 52         166 DEBUG "delete session from disk '$key'";
353              
354 52         374 my $fname = $self->cfg->{SESSIONS_DIR} . "/$key";
355              
356 52 50       2734 unlink $fname or die "Couldn't unlink '$fname': $!";
357             }
358              
359             =item $capo->mk_cookie()
360              
361             Generate a I cookie with random- and session-data or use the already existing session cookie. The cookie is used to fast reactivate an idle session if the IP/MAC/COOKIE is still matching. Cookies are not mandatory, they are just for a better user experience.
362              
363             =cut
364              
365             sub mk_cookie {
366 2     2 1 6 my $self = shift;
367              
368 2 50       13 my $session = $self->{CTX}{SESSION}
369             or LOGDIE "FATAL: missing 'SESSION' in run CTX,";
370              
371 2 50       21 my $query = $self->{CTX}{QUERY}
372             or LOGDIE "FATAL: missing 'QUERY' in run CTX,";
373              
374 2         6 my $value;
375 2 100       9 if ( $value = $session->{COOKIE} ) {
376 1         6 DEBUG 'use stored cookie-value from session data';
377             }
378             else {
379 1         5 DEBUG 'generate cookie with session- and random-data';
380              
381 1         25 $value = md5_hex(
382             time()
383             . $session->{IP}
384             . $session->{MAC}
385             . $session->{USERNAME}
386             . int( rand(100000) ) );
387             }
388              
389 2 50       28 my $cookie = $query->cookie(
    50          
390             -name => 'CaPo',
391             -value => $value,
392             -httponly => 1,
393             $self->cfg->{SSL_REQUIRED} ? ( -secure => 1 ) : (),
394             ) or LOGDIE "Couldn't create cookie\n";
395              
396 2         5711 return $cookie;
397             }
398              
399             =item $capo->match_cookie()
400              
401             Check if request cookie is equal session cookie. Returns true on success and false on failure.
402              
403             =cut
404              
405             sub match_cookie {
406 0     0 1   my $self = shift;
407              
408 0           DEBUG "compare request cookie with session cookie";
409              
410 0 0         my $query = $self->{CTX}{QUERY}
411             or LOGDIE "FATAL: missing 'QUERY' in run CTX,";
412              
413 0 0         my $session = $self->{CTX}{SESSION}
414             or LOGDIE "FATAL: missing 'SESSION' in run CTX,";
415              
416 0 0         return unless $session->{COOKIE};
417              
418 0           my $request_cookie = $query->cookie('CaPo');
419 0 0         return unless $request_cookie;
420              
421 0 0         return 1 if $request_cookie eq $session->{COOKIE};
422              
423 0           return;
424             }
425              
426             1;
427              
428             =back
429              
430             =head1 AUTHOR
431              
432             Karl Gaissmaier, C<< >>
433              
434             =head1 LICENSE AND COPYRIGHT
435              
436             Copyright 2010-2013 Karl Gaissmaier, all rights reserved.
437              
438             This distribution is free software; you can redistribute it and/or modify it
439             under the terms of either:
440              
441             a) the GNU General Public License as published by the Free Software
442             Foundation; either version 2, or (at your option) any later version, or
443              
444             b) the Artistic License version 2.0.
445              
446             =cut
447              
448             # vim: sw=4