File Coverage

lib/PAGI/Middleware/Auth/Basic.pm
Criterion Covered Total %
statement 62 74 83.7
branch 12 26 46.1
condition 7 11 63.6
subroutine 12 12 100.0
pod 1 1 100.0
total 94 124 75.8


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Auth::Basic;
2              
3 2     2   548 use strict;
  2         3  
  2         64  
4 2     2   7 use warnings;
  2         2  
  2         77  
5 2     2   6 use parent 'PAGI::Middleware';
  2         3  
  2         9  
6 2     2   90 use Future::AsyncAwait;
  2         2  
  2         9  
7 2     2   443 use MIME::Base64 qw(decode_base64);
  2         707  
  2         2231  
8              
9             =head1 NAME
10              
11             PAGI::Middleware::Auth::Basic - HTTP Basic Authentication middleware
12              
13             =head1 SYNOPSIS
14              
15             use PAGI::Middleware::Builder;
16              
17             my $app = builder {
18             enable 'Auth::Basic',
19             realm => 'Restricted Area',
20             authenticator => sub {
21             my ($username, $password) = @_;
22             return $username eq 'admin' && $password eq 'secret';
23             };
24             $my_app;
25             };
26              
27             # In your app:
28             async sub app {
29             my ($scope, $receive, $send) = @_;
30              
31             my $auth = $scope->{'pagi.auth'};
32             my $username = $auth->{username};
33             }
34              
35             =head1 DESCRIPTION
36              
37             PAGI::Middleware::Auth::Basic implements HTTP Basic Authentication (RFC 7617).
38             It validates credentials and returns 401 Unauthorized for failed authentication.
39              
40             =head1 CONFIGURATION
41              
42             =over 4
43              
44             =item * authenticator (required)
45              
46             Coderef that receives ($username, $password) and returns true for valid credentials.
47              
48             =item * realm (default: 'Restricted')
49              
50             The authentication realm shown in the WWW-Authenticate header.
51              
52             =item * paths (optional)
53              
54             Arrayref of path patterns to protect. If not specified, all paths are protected.
55              
56             =back
57              
58             =cut
59              
60             sub _init {
61 3     3   5 my ($self, $config) = @_;
62              
63             $self->{authenticator} = $config->{authenticator}
64 3   50     13 // die "Auth::Basic requires 'authenticator' option";
65 3   100     12 $self->{realm} = $config->{realm} // 'Restricted';
66 3         7 $self->{paths} = $config->{paths};
67             }
68              
69             sub wrap {
70 3     3 1 22 my ($self, $app) = @_;
71              
72 3     3   65 return async sub {
73 3         6 my ($scope, $receive, $send) = @_;
74 3 50       8 if ($scope->{type} ne 'http') {
75 0         0 await $app->($scope, $receive, $send);
76 0         0 return;
77             }
78              
79             # Check if path requires authentication
80 3 50       6 unless ($self->_requires_auth($scope->{path})) {
81 0         0 await $app->($scope, $receive, $send);
82 0         0 return;
83             }
84              
85             # Get Authorization header
86 3         8 my $auth_header = $self->_get_header($scope, 'authorization');
87              
88 3 100       6 unless ($auth_header) {
89 1         4 await $self->_send_unauthorized($send);
90 1         46 return;
91             }
92              
93             # Parse Basic authentication
94 2         4 my ($username, $password) = $self->_parse_basic_auth($auth_header);
95              
96 2 50       4 unless (defined $username) {
97 0         0 await $self->_send_unauthorized($send);
98 0         0 return;
99             }
100              
101             # Validate credentials
102 2         3 my $valid = eval { $self->{authenticator}->($username, $password) };
  2         5  
103 2 100 66     22 if ($@ || !$valid) {
104 1         21 await $self->_send_unauthorized($send);
105 1         45 return;
106             }
107              
108             # Add auth info to scope
109 1         5 my $new_scope = {
110             %$scope,
111             'pagi.auth' => {
112             type => 'basic',
113             username => $username,
114             },
115             };
116              
117 1         3 await $app->($new_scope, $receive, $send);
118 3         13 };
119             }
120              
121             sub _requires_auth {
122 3     3   6 my ($self, $path) = @_;
123              
124 3 50       10 return 1 unless $self->{paths};
125              
126 0         0 for my $pattern (@{$self->{paths}}) {
  0         0  
127 0 0       0 if (ref $pattern eq 'Regexp') {
128 0 0       0 return 1 if $path =~ $pattern;
129             } else {
130 0 0       0 return 1 if index($path, $pattern) == 0;
131             }
132             }
133 0         0 return 0;
134             }
135              
136             sub _parse_basic_auth {
137 2     2   4 my ($self, $header) = @_;
138              
139 2 50       14 return unless $header =~ /^Basic\s+(.+)$/i;
140              
141 2         4 my $encoded = $1;
142 2         35 my $decoded = eval { decode_base64($encoded) };
  2         6  
143 2 50       5 return unless $decoded;
144              
145 2         6 my ($username, $password) = split /:/, $decoded, 2;
146 2 50       5 return unless defined $username;
147              
148 2   50     5 return ($username, $password // '');
149             }
150              
151 2     2   4 async sub _send_unauthorized {
152 2         3 my ($self, $send) = @_;
153              
154 2         3 my $realm_escaped = $self->{realm};
155 2         4 $realm_escaped =~ s/"/\\"/g;
156              
157 2         2 my $body = 'Unauthorized';
158              
159 2         25 await $send->({
160             type => 'http.response.start',
161             status => 401,
162             headers => [
163             ['Content-Type', 'text/plain'],
164             ['Content-Length', length($body)],
165             ['WWW-Authenticate', qq{Basic realm="$realm_escaped", charset="UTF-8"}],
166             ],
167             });
168 2         85 await $send->({
169             type => 'http.response.body',
170             body => $body,
171             more => 0,
172             });
173             }
174              
175             sub _get_header {
176 3     3   5 my ($self, $scope, $name) = @_;
177              
178 3         6 $name = lc($name);
179 3   50     3 for my $h (@{$scope->{headers} // []}) {
  3         8  
180 2 50       8 return $h->[1] if lc($h->[0]) eq $name;
181             }
182 1         2 return;
183             }
184              
185             1;
186              
187             __END__