Skip to content

Commit cdf71a4

Browse files
david-dickoalders
authored andcommitted
Adding otpauth URI
1 parent 6c91e5f commit cdf71a4

File tree

4 files changed

+445
-0
lines changed

4 files changed

+445
-0
lines changed

cpanfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ on 'runtime' => sub {
2828
requires "Data::Dumper" => "0";
2929
requires "Encode" => "0";
3030
requires "Exporter" => "5.57";
31+
requires "MIME::Base32" => "0";
3132
requires "MIME::Base64" => "2";
3233
requires "Net::Domain" => "0";
3334
requires "Scalar::Util" => "0";

dist.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ filename = t/escape.t
2323
filename = t/http.t
2424
filename = t/icap.t
2525
filename = t/old-base.t
26+
filename = t/otpauth.t
2627
filename = t/pop.t
2728
filename = t/rtsp.t
2829
filename = uri-test

lib/URI/otpauth.pm

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package URI::otpauth;
2+
3+
use warnings;
4+
use strict;
5+
use MIME::Base32();
6+
use URI::Split();
7+
use URI::Escape();
8+
9+
use parent qw( URI URI::_query );
10+
11+
our $VERSION = '5.29';
12+
13+
sub new {
14+
my ($class, @parameters) = @_;
15+
my %fields = $class->_set(@parameters);
16+
my $uri = URI::Split::uri_join(
17+
'otpauth', $fields{type},
18+
$class->_path(%fields),
19+
$class->_query(%fields),
20+
);
21+
return bless \$uri, $class;
22+
}
23+
24+
sub _parse {
25+
my $self = shift;
26+
my ($scheme, $type, $path, $query, $frag) = URI::Split::uri_split(${$self});
27+
$path =~ s/^\///smxg;
28+
my @path_parts = split /:/smx, $path;
29+
my ($issuer_prefix, $account_name);
30+
if (scalar @path_parts == 1) {
31+
$account_name = $path_parts[0];
32+
}
33+
else {
34+
$issuer_prefix = $path_parts[0];
35+
$account_name = $path_parts[1];
36+
}
37+
my %fields = (label => $path, type => $type, account_name => $account_name);
38+
my $issuer_parameter = $self->query_param('issuer');
39+
if (defined $issuer_parameter) {
40+
if ((defined $issuer_prefix) && ($issuer_prefix ne $issuer_parameter)) {
41+
Carp::carp(
42+
"Issuer prefix from label '$issuer_prefix' does not match issuer parameter '$issuer_parameter'"
43+
);
44+
}
45+
$fields{issuer} = $issuer_parameter;
46+
}
47+
elsif (defined $issuer_prefix) {
48+
$fields{issuer} = URI::Escape::uri_unescape($issuer_prefix);
49+
}
50+
if (my $encoded_secret = $self->query_param('secret')) {
51+
$fields{secret} = MIME::Base32::decode_base32($encoded_secret);
52+
}
53+
foreach my $name (qw(algorithm digits counter period)) {
54+
if (my $value = $self->query_param($name)) {
55+
$fields{$name} = $value;
56+
}
57+
}
58+
%fields = $self->_set(%fields);
59+
return ($scheme, $fields{type}, \%fields, $query, $frag);
60+
}
61+
62+
my $label_escape_regex = qr/[^[:alnum:]@.]/smx;
63+
64+
sub _set {
65+
my ($self, %fields) = @_;
66+
delete $fields{label};
67+
if (defined $fields{account_name}) {
68+
if (defined $fields{issuer}) {
69+
$fields{label} = $fields{issuer} . q[:] . $fields{account_name};
70+
}
71+
else {
72+
$fields{label} = $fields{account_name};
73+
}
74+
}
75+
if (!length $fields{type}) {
76+
$fields{type} = 'totp';
77+
}
78+
return %fields;
79+
}
80+
81+
my %field_names = map { $_ => 1 }
82+
qw(secret label counter algorithm period digits issuer type account_name);
83+
my @query_names = qw(secret issuer algorithm digits counter period);
84+
my %defaults = (algorithm => 'SHA1', digits => 6, type => 'totp', period => 30);
85+
86+
sub _field {
87+
my ($self, $name, @remainder) = @_;
88+
my ($scheme, $type, $fields, $query, $frag) = $self->_parse();
89+
90+
if (!@remainder) {
91+
if (defined $fields->{$name}) {
92+
return $fields->{$name};
93+
}
94+
else {
95+
return $defaults{$name};
96+
}
97+
}
98+
$fields->{$name} = shift @remainder;
99+
${$self} = URI::Split::uri_join(
100+
$scheme, $fields->{type},
101+
$self->_path(%{$fields}),
102+
$self->_query(%{$fields}), $frag
103+
);
104+
return $self;
105+
}
106+
107+
sub _query {
108+
my ($class, %fields) = @_;
109+
if (defined $fields{secret}) {
110+
$fields{secret} = MIME::Base32::encode_base32($fields{secret});
111+
}
112+
else {
113+
Carp::croak('secret is a mandatory parameter for ' . __PACKAGE__);
114+
}
115+
return join q[&],
116+
map { join q[=], $_ => $fields{$_} }
117+
grep { exists $fields{$_} } @query_names;
118+
}
119+
120+
sub _path {
121+
my ($class, %fields) = @_;
122+
my $path = $fields{label};
123+
return $path;
124+
}
125+
126+
sub type {
127+
my ($self, @parameters) = @_;
128+
return $self->_field('type', @parameters);
129+
}
130+
131+
sub label {
132+
my ($self, @parameters) = @_;
133+
return $self->_field('label', @parameters);
134+
}
135+
136+
sub account_name {
137+
my ($self, @parameters) = @_;
138+
return $self->_field('account_name', @parameters);
139+
}
140+
141+
sub issuer {
142+
my ($self, @parameters) = @_;
143+
return $self->_field('issuer', @parameters);
144+
}
145+
146+
sub secret {
147+
my ($self, @parameters) = @_;
148+
return $self->_field('secret', @parameters);
149+
}
150+
151+
sub algorithm {
152+
my ($self, @parameters) = @_;
153+
return $self->_field('algorithm', @parameters);
154+
}
155+
156+
sub counter {
157+
my ($self, @parameters) = @_;
158+
return $self->_field('counter', @parameters);
159+
}
160+
161+
sub digits {
162+
my ($self, @parameters) = @_;
163+
return $self->_field('digits', @parameters);
164+
}
165+
166+
sub period {
167+
my ($self, @parameters) = @_;
168+
return $self->_field('period', @parameters);
169+
}
170+
171+
1;
172+
173+
__END__
174+
175+
=head1 NAME
176+
177+
URI::otpauth - URI scheme for secret keys for OTP secrets. Usually found in QR codes
178+
179+
=head1 VERSION
180+
181+
Version 5.29
182+
183+
=head1 SYNOPSIS
184+
185+
use URI;
186+
187+
# optauth URI from textual uri
188+
my $uri = URI->new( 'otpauth://totp/Example:[email protected]?secret=NFZS25DINFZV643VOAZXELLTGNRXEM3UH4&issuer=Example' );
189+
190+
# same URI but created from arguments
191+
my $uri = URI::otpauth->new( type => 'totp', issuer => 'Example', account_name => '[email protected]', secret => 'is-this_sup3r-s3cr3t?' );
192+
193+
=head1 DESCRIPTION
194+
195+
This URI scheme is defined in L<https://github.com/google/google-authenticator/wiki/Key-Uri-Format/>:
196+
197+
=head1 SUBROUTINES/METHODS
198+
199+
=head2 C<< new >>
200+
201+
Create a new URI::otpauth. The available arguments are listed below;
202+
203+
=over
204+
205+
=item * account_name - this can be the account name (probably an email address) used when authenticating with this secret. It is an optional field.
206+
207+
=item * algorithm - this is the L<cryptographic hash function|https://en.wikipedia.org/wiki/Cryptographic_hash_function> that should be used. Current values are L<SHA1|https://en.wikipedia.org/wiki/SHA-1>, L<SHA256|https://en.wikipedia.org/wiki/SHA-2> or L<SHA512|https://en.wikipedia.org/wiki/SHA-2>. It is an optional field and will default to SHA1.
208+
209+
=item * counter - this is only required when the type is HOTP.
210+
211+
=item * digits - this determines the L<length|https://github.com/google/google-authenticator/wiki/Key-Uri-Format/#digits> of the code presented to the user. It is an optional field and will default to 6 digits.
212+
213+
=item * issuer - this can be the L<application / system|https://github.com/google/google-authenticator/wiki/Key-Uri-Format/#issuer> that this secret can be used to authenticate to. It is an optional field.
214+
215+
=item * label - this is the L<issuer and the account name|https://github.com/google/google-authenticator/wiki/Key-Uri-Format/#label> joined with a ":" character. It is an optional field.
216+
217+
=item * period - this is the L<period that the TOTP code is valid for|https://github.com/google/google-authenticator/wiki/Key-Uri-Format/#counter>. It is an optional field and will default to 30 seconds.
218+
219+
=item * secret - this is the L<key|https://en.wikipedia.org/wiki/Key_(cryptography)> that the L<TOTP|https://en.wikipedia.org/wiki/Time-based_one-time_password>/L<HOTP|https://en.wikipedia.org/wiki/HMAC-based_one-time_password> algorithm uses to derive the value. It is an arbitrary byte string and must remain private. This field is mandatory.
220+
221+
=item * type - this can be 'L<hotp|https://en.wikipedia.org/wiki/HMAC-based_one-time_password>' or 'L<totp|https://en.wikipedia.org/wiki/Time-based_one-time_password>'. This field will default to 'totp'.
222+
223+
=back
224+
225+
=head2 C<algorithm>
226+
227+
Get or set the algorithm of this otpauth URI.
228+
229+
=head2 C<account_name>
230+
231+
Get or set the account_name of this otpauth URI.
232+
233+
=head2 C<counter>
234+
235+
Get or set the counter of this otpauth URI.
236+
237+
=head2 C<digits>
238+
239+
Get or set the digits of this otpauth URI.
240+
241+
=head2 C<issuer>
242+
243+
Get or set the issuer of this otpauth URI.
244+
245+
=head2 C<label>
246+
247+
Get or set the label of this otpauth URI.
248+
249+
=head2 C<period>
250+
251+
Get or set the period of this otpauth URI.
252+
253+
=head2 C<secret>
254+
255+
Get or set the secret of this otpauth URI.
256+
257+
=head2 C<type>
258+
259+
Get or set the type of this otpauth URI.
260+
261+
my $type = $uri->type('hotp');
262+
263+
=head1 CONFIGURATION AND ENVIRONMENT
264+
265+
URI::otpauth requires no configuration files or environment variables.
266+
267+
=head1 DEPENDENCIES
268+
269+
L<URI>
270+
271+
=head1 DIAGNOSTICS
272+
273+
=over
274+
275+
=item C<< secret is a mandatory parameter for URI::otpauth >>
276+
277+
The secret parameter was not detected for the URI::otpauth->new() method.
278+
279+
=back
280+
281+
=head1 INCOMPATIBILITIES
282+
283+
None reported.
284+
285+
=head1 BUGS AND LIMITATIONS
286+
287+
To report a bug, or view the current list of bugs, please visit L<https://github.com/libwww-perl/URI/issues>
288+
289+
=head1 AUTHOR
290+
291+
David Dick C<< <[email protected]> >>
292+
293+
=head1 LICENSE AND COPYRIGHT
294+
295+
Copyright (c) 2024, David Dick C<< <[email protected]> >>.
296+
297+
This module is free software; you can redistribute it and/or
298+
modify it under the same terms as Perl itself. See L<perlartistic>.

0 commit comments

Comments
 (0)