|
| 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