our
$VERSION
=
'0.03'
;
sub
UserSettings {
my
(
$UserObj
) =
@_
;
my
$Settings
= {
'Type'
=>
'None'
,
'Duration'
=> 86400,
'Secret'
=>
''
,
'Yubikey'
=>
''
};
return
$Settings
if
(not
$UserObj
);
return
$Settings
if
(not
$UserObj
->id);
foreach
my
$Key
(
keys
%$Settings
) {
my
$Attribute
=
$UserObj
->FirstAttribute(
'TOTPMFA'
.
$Key
);
next
if
(not
defined
$Attribute
);
my
$Value
=
$Attribute
->Content;
next
if
(not
defined
$Value
);
$Settings
->{
$Key
} =
$Value
;
}
return
$Settings
;
}
sub
UpdateUserSetting {
my
(
$UserObj
,
$Key
,
$Value
) =
@_
;
my
(
$Settings
,
$OK
,
$Message
);
return
(0,
'No user object provided'
)
if
(not
$UserObj
);
return
(0,
'No user object loaded'
)
if
(not
$UserObj
->id);
$Settings
= RT::Extension::TOTPMFA::UserSettings(
$UserObj
);
return
(0,
$UserObj
->loc(
'No TOTPMFA settings key provided'
))
if
(not
defined
$Key
);
return
(0,
$UserObj
->loc(
'Unknown TOTPMFA settings key: [_1]'
,
$Key
))
if
(not
exists
$Settings
->{
$Key
});
return
(0,
$UserObj
->loc(
'No TOTPMFA settings value provided for key [_1]'
,
$Key
))
if
(not
defined
$Value
);
return
(1,
''
)
if
(
$Settings
->{
$Key
} eq
$Value
);
(
$OK
,
$Message
) =
$UserObj
->SetAttribute(
'Name'
=>
'TOTPMFA'
.
$Key
,
'Description'
=>
''
,
'Content'
=>
$Value
);
return
(
$OK
,
$Message
)
if
(not
$OK
);
if
(
$Key
eq
'Secret'
) {
$UserObj
->_NewTransaction(
Type
=>
'Set'
,
Field
=>
'TOTPMFA:'
.
$Key
,
OldValue
=>
'(old secret)'
,
NewValue
=>
'(new secret)'
);
}
else
{
$UserObj
->_NewTransaction(
Type
=>
'Set'
,
Field
=>
'TOTPMFA:'
.
$Key
,
OldValue
=>
$Settings
->{
$Key
},
NewValue
=>
$Value
);
}
return
(1,
$UserObj
->loc(
'Updated TOTPMFA "[_1]" value.'
,
$Key
));
}
sub
IsEnabledForUser {
my
(
$UserObj
) =
@_
;
return
0
if
(not
defined
$UserObj
);
return
0
if
(not
$UserObj
->id);
return
0
if
(RT::Extension::TOTPMFA::UserSettings(
$UserObj
)->{
'Type'
} eq
'None'
);
return
1;
}
sub
NewSecret {
my
(
$UserObj
) =
@_
;
my
(
$Settings
,
$NewSecret
,
$OK
,
$Message
);
return
(0,
'No user object provided'
)
if
(not
$UserObj
);
return
(0,
'No user object loaded'
)
if
(not
$UserObj
->id);
$Settings
= RT::Extension::TOTPMFA::UserSettings(
$UserObj
);
$NewSecret
= Convert::Base32::encode_base32(Crypt::CBC->random_bytes(16));
return
RT::Extension::TOTPMFA::UpdateUserSetting(
$UserObj
,
'Secret'
,
$NewSecret
);
}
sub
SessionIsAuthenticated {
my
(
$Session
) =
@_
;
return
0
if
(not
defined
$Session
);
return
0
if
(not
$Session
->{
'TOTPMFAValidUntil'
});
return
0
if
(
$Session
->{
'TOTPMFAValidUntil'
} !~ /^[0-9]+$/);
return
0
if
(
$Session
->{
'TOTPMFAValidUntil'
} <
time
);
return
1;
}
sub
QRCode {
my
(
$UserObj
) =
@_
;
my
(
$Settings
);
my
(
$Label
,
$Secret
,
$Issuer
,
$Period
,
$Algorithm
,
$Digits
);
my
(
$URL
,
$QRCode
,
$Image
,
$PNG
);
$Settings
= RT::Extension::TOTPMFA::UserSettings(
$UserObj
);
if
(
length
$Settings
->{
'Secret'
} < 1) {
RT::Extension::TOTPMFA::NewSecret(
$UserObj
);
$Settings
= RT::Extension::TOTPMFA::UserSettings(
$UserObj
);
}
$Label
=
$UserObj
->Name;
$Secret
=
$Settings
->{
'Secret'
};
$Issuer
= RT->Config->Get(
'TOTPMFA_Issuer'
) ||
'Request Tracker'
;
$Period
= RT->Config->Get(
'TOTPMFA_Period'
) || 30;
$Algorithm
=
'SHA1'
;
$Digits
= RT->Config->Get(
'TOTPMFA_Digits'
) || 6;
$URL
=
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$Label
,
'u'
)
.
'?secret='
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$Secret
,
'u'
)
.
'&issuer='
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$Issuer
,
'u'
)
.
'&period='
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$Period
,
'u'
)
.
'&algorithm='
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$Algorithm
,
'u'
)
.
'&digits='
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$Digits
,
'u'
);
$QRCode
= Imager::QRCode->new(
'size'
=> 6,
'margin'
=> 3,
'level'
=>
'L'
,
'version'
=> 0,
'mode'
=>
'8-bit'
,
'casesensitive'
=> 1
);
$Image
=
$QRCode
->plot(
$URL
);
$PNG
=
''
;
$Image
->
write
(
'data'
=> \
$PNG
,
'type'
=>
'png'
);
return
$PNG
;
}
sub
MFALogin {
my
(
$Session
,
$OTP
) =
@_
;
my
(
$UserObj
,
$Settings
);
my
(
$Period
,
$Algorithm
,
$Digits
);
$UserObj
=
$Session
->{
'CurrentUser'
}->UserObj;
$Settings
= RT::Extension::TOTPMFA::UserSettings(
$UserObj
);
$Period
= RT->Config->Get(
'TOTPMFA_Period'
) || 30;
$Digits
= RT->Config->Get(
'TOTPMFA_Digits'
) || 6;
if
(
$OTP
=~ /^[0-9]+$/) {
return
(0,
$UserObj
->loc(
'No TOTP secret registered'
))
if
(not
defined
$Settings
->{
'Secret'
});
my
$OATH
= Authen::OATH->new(
'digits'
=>
$Digits
,
'timestep'
=>
$Period
);
my
$CheckValue
=
$OATH
->totp(Convert::Base32::decode_base32(
$Settings
->{
'Secret'
}));
my
$MatchingOTP
= 0;
$MatchingOTP
= 1
if
(
$CheckValue
eq
$OTP
);
if
(not
$MatchingOTP
) {
$CheckValue
=
$OATH
->totp(Convert::Base32::decode_base32(
$Settings
->{
'Secret'
}),
time
-
$Period
);
$MatchingOTP
= 1
if
(
$CheckValue
eq
$OTP
);
}
if
(not
$MatchingOTP
) {
$CheckValue
=
$OATH
->totp(Convert::Base32::decode_base32(
$Settings
->{
'Secret'
}),
time
+
$Period
);
$MatchingOTP
= 1
if
(
$CheckValue
eq
$OTP
);
}
return
(0,
$UserObj
->loc(
'One-time passcode does not match.'
))
if
(not
$MatchingOTP
);
}
elsif
(
$OTP
=~ /[a-z]{32}/) {
my
$Yubikey
=
$Settings
->{
'Yubikey'
};
if
(
defined
$Yubikey
) {
$Yubikey
=~ s/[a-z]{32}\s*$//s;
$Yubikey
=~ s/^\s*//s;
$Yubikey
=
undef
if
(
$Yubikey
!~ /[a-z]{12}/);
}
return
(0,
$UserObj
->loc(
'No Yubikey device registered'
))
if
(not
defined
$Yubikey
);
return
(
0,
$UserObj
->loc(
'One-time passcode is not from the registered Yubikey device.'
)
)
if
(
$OTP
!~ /^\Q
$Yubikey
\E/);
my
$NumberUsedOnce
=
Convert::Base32::encode_base32(Crypt::CBC->random_bytes(20));
my
$UserAgent
= LWP::UserAgent->new;
$UserAgent
->timeout(30);
my
$URL
=
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$OTP
,
'u'
)
.
'&nonce='
.
$HTML::Mason::Commands::m
->interp->apply_escapes(
$NumberUsedOnce
,
'u'
);
my
$Response
=
$UserAgent
->get(
$URL
);
if
(not
$Response
) {
return
(0,
$UserObj
->loc(
'One-time passcode validation API call failed'
));
}
elsif
(
$Response
->is_error) {
return
(
0,
$UserObj
->loc(
'One-time passcode validation API call failed with this error: [_1]'
,
$Response
->status_line
)
);
}
my
$Content
=
$Response
->content
if
(
defined
$Response
);
$Content
=
''
if
(not
defined
$Content
);
if
(
$Content
=~ s/^.
*status
=([A-Z_]+).*?/$1/s) {
return
(
0,
$UserObj
->loc(
'One-time passcode validation failed with this status response: [_1]'
,
$Content
)
)
if
(
$Content
ne
'OK'
);
}
else
{
return
(
0,
$UserObj
->loc(
'One-time passcode validation failed with no valid status response'
,
$Content
)
);
}
}
else
{
return
(0,
$UserObj
->loc(
'One-time passcode does not match a known format.'
));
}
$Session
->{
'TOTPMFAValidUntil'
} =
time
+
$Settings
->{
'Duration'
};
return
(1,
''
);
}
1;