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   504 use strict;
  2         3  
  2         66  
4 2     2   6 use warnings;
  2         2  
  2         78  
5 2     2   7 use parent 'PAGI::Middleware';
  2         2  
  2         10  
6 2     2   92 use Future::AsyncAwait;
  2         2  
  2         8  
7 2     2   459 use MIME::Base64 qw(decode_base64);
  2         734  
  2         2290  
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   8 my ($self, $config) = @_;
62              
63             $self->{authenticator} = $config->{authenticator}
64 3   50     18 // die "Auth::Basic requires 'authenticator' option";
65 3   100     21 $self->{realm} = $config->{realm} // 'Restricted';
66 3         10 $self->{paths} = $config->{paths};
67             }
68              
69             sub wrap {
70 3     3 1 31 my ($self, $app) = @_;
71              
72 3     3   105 return async sub {
73 3         7 my ($scope, $receive, $send) = @_;
74 3 50       12 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       11 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         11 my $auth_header = $self->_get_header($scope, 'authorization');
87              
88 3 100       26 unless ($auth_header) {
89 1         4 await $self->_send_unauthorized($send);
90 1         70 return;
91             }
92              
93             # Parse Basic authentication
94 2         7 my ($username, $password) = $self->_parse_basic_auth($auth_header);
95              
96 2 50       5 unless (defined $username) {
97 0         0 await $self->_send_unauthorized($send);
98 0         0 return;
99             }
100              
101             # Validate credentials
102 2         4 my $valid = eval { $self->{authenticator}->($username, $password) };
  2         10  
103 2 100 66     49 if ($@ || !$valid) {
104 1         4 await $self->_send_unauthorized($send);
105 1         90 return;
106             }
107              
108             # Add auth info to scope
109 1         9 my $new_scope = {
110             %$scope,
111             'pagi.auth' => {
112             type => 'basic',
113             username => $username,
114             },
115             };
116              
117 1         4 await $app->($new_scope, $receive, $send);
118 3         19 };
119             }
120              
121             sub _requires_auth {
122 3     3   7 my ($self, $path) = @_;
123              
124 3 50       15 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   5 my ($self, $header) = @_;
138              
139 2 50       19 return unless $header =~ /^Basic\s+(.+)$/i;
140              
141 2         53 my $encoded = $1;
142 2         6 my $decoded = eval { decode_base64($encoded) };
  2         10  
143 2 50       8 return unless $decoded;
144              
145 2         9 my ($username, $password) = split /:/, $decoded, 2;
146 2 50       7 return unless defined $username;
147              
148 2   50     11 return ($username, $password // '');
149             }
150              
151 2     2   5 async sub _send_unauthorized {
152 2         5 my ($self, $send) = @_;
153              
154 2         5 my $realm_escaped = $self->{realm};
155 2         6 $realm_escaped =~ s/"/\\"/g;
156              
157 2         4 my $body = 'Unauthorized';
158              
159 2         20 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         134 await $send->({
169             type => 'http.response.body',
170             body => $body,
171             more => 0,
172             });
173             }
174              
175             sub _get_header {
176 3     3   8 my ($self, $scope, $name) = @_;
177              
178 3         8 $name = lc($name);
179 3   50     6 for my $h (@{$scope->{headers} // []}) {
  3         14  
180 2 50       12 return $h->[1] if lc($h->[0]) eq $name;
181             }
182 1         3 return;
183             }
184              
185             1;
186              
187             __END__