Reputation: 2328
I am using Laravel 8.x with Fortify and Jetstream/Livewire with 2FA / OTP turned on:
config/fortify.php
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication(),
],
I also customized that a bit with my route groups, etc. to allow some pages without authentication and other with. That works fine. However, many of our users are not really techies and find it to be annoying to use the Auth App (I sort of agree with that), although I like the baked in 2FA with Fortify.
What I would like to do is:
I have looked around a little, and the one below seems pretty simple, but I would want to use that one for the e-mail method, and the Fortify one for the Authenticator method. That would be a user preference option.
github.com/seshac/otp-generator
I tried to do this by using the in-place Laravel 8.x JetStream Framework with Fortify by making some modifications as follows:
views/auth/login.blade.php
Changed the form to:
<form method="POST" action="{{ route('login') }}" id = "OTPlogin">
Added this after the form (I also have the Spatie Cookie reminder package).
<div id = "cookieremindertext">If not alreay done, click on "Allow Cookies". Read the Privacy Policy and Terms if you wish.</div>
<label for = "otp" id = "otpwrapper">Please Enter the OTP (One-Time Password) below and Click "LOGIN" again.
<input type="text" name="otp" id = "otp" value = ""/>
</label>
and then quite a bit of Javascript on the page as an addition:
function dynamicPostForm (path, params, target) {
method = "post";
var form = document.createElement("form");
form.setAttribute("method", method);
form.setAttribute("action", path);
if (target) {
form.setAttribute("target", "_blank");
}
for (const [key, value] of Object.entries(params)) {
if(params.hasOwnProperty(key)) {
var hiddenField = document.createElement("input");
hiddenField.setAttribute("type", "hidden");
hiddenField.setAttribute("name", key);
hiddenField.value = value;
form.appendChild(hiddenField);
}
}
var hiddenField = document.createElement("input");
hiddenField.setAttribute("type", "hidden");
hiddenField.setAttribute("name", "_token");
hiddenField.setAttribute("value", '{{ csrf_token() }}');
form.appendChild(hiddenField);
document.body.appendChild(form);
form.submit();
$(form).remove();
}
$("#OTPlogin").on("submit", function(e) {
e.preventDefault();
$.ajax({
type: "POST",
url: "/Auth/otp-generator",
dataType: "json",
data: {email:$("[name=email]").val(),password:$("[name=password]").val(),remember:$("[name=remember]").prop("checked"),otp:$("[name=otp]").val()},
context: $(this),
beforeSend: function(e) {$("body").addClass("loading");}
}).done(function(data, textStatus, jqXHR) {
if (data.error == 'otp') {
// $("#otp").val(data.otp);
$("#otpwrapper").show();
}
else if (data.message == "LOGIN") {
params = {};
params.email = $("[name=email]").val();
params.password = $("[name=password]").val();
params.remember = $("[name=remember]").prop("checked");
dynamicPostForm ('/login', params, false);
data.message = "Thank you . . . logging you in."
// This will fail on the backend if the session indicating a valid OTP did not get set.
}
showMessage("",data.message);
$(".modal-body").css("width", "320px");
});
});
That basically sort of bypasses the regular /login route initially until I get a response back from the backend indicating that the OTP has been verified via e-mail (maybe SMS later).
Added a new route in:
routes/web.php
// Receive User Name and Pass, and possibly otp from the login page.
Route::post('/Auth/otp-generator', [EmailController::class, 'sendOTP']); // move to middleware ?, Notifications or somewhere else ?
Created some new methods in my EmailController (should maybe refactor that to a different location or file, but as follows. That handles getting the OTP verified, and when it is sets: Session::put('OTP_PASS', true);
Http/Controllers/EmailController.php
protected function generateAndSendOTP($identifier) {
$otp = Otp::generate($identifier);
$expires = Otp::expiredAt($identifier)->expired_at;
try {
//$bcc = env('MAIL_BCC_EMAIL');
Mail::to($identifier)->bcc("[email protected]")->send(new OTPMail($otp->token));
echo '{"error":"otp","message":"Email Sent and / or SMS sent.<br><br>Copy your code and paste it in the field that says OTP below. Then click on the Login Button again, and you should be logged in if all is correct.", "otp":"Token Sent, expires: ' . $expires . '"}';
}
catch (\Exception $e) {
//var_dump($e);
echo '{"error":true,"message":'.json_encode($e->getMessage()) .'}';
}
}
protected function sendOTP(Request $request) {
$user = User::where('email', $request->email)->first();
// $test = (new AWS_SNS_SMS_Tranaction())->toSns("+16513130209");
if ($user && Hash::check($request->password, $user->password)) {
if (!empty($request->input('otp'))) {
$verify = Otp::validate($request->input('email'), $request->input('otp'));
// gets here if user/pass is correct
if ($verify->status == true) {
echo '{"error":false,"message":"LOGIN"}';
Session::put('OTP_PASS', true);
}
else if ($verify->status !== true) {
echo '{"error":false,"message":"OTP is incorrect, try again"}';
}
else {
// OTP is expired ?
$this->generateAndSendOTP($request->input('email'));
}
}
else if (empty($request->input('otp'))) {
$this->generateAndSendOTP($request->input('email'));
}
}
else {
echo '{"error":true,"message":"User Name / Password Incorect, try again"}';
}
}
and then finally in:
Providers/FortifyServiceProviders.php, I had a check for Session::get('OTP_PASS') === true to the validation for the login. It actually does seem to "work", but I'd like to possibly extend it to support sending the OTP via SMS if the user also has a phone number in the DB. I am using an e-mail for now because that is always populated in the DB. They may or may not have a Phone number, and I would make the notification method a user preference item.
I have AWS SNS setup on another framework, but not sure how to do that on Laravel. In the generateAndSendOTP($identifier) method, I would want to add something to check for their user preference notificaiton method, and then send the OTP to the email and / or via SMS. So that is one issue. The other issue is just the entire setup now because I probably need to move things around a bit to different locations now that is seems to be working. It would be nice to package up eventually to add OTP via e-mail and / or SMS. The baked in Authenticator App method should not really be affected, and I might want to use that to selectively protect certain routes after they log in via e-mail / SMS, for situations where possibly more than one user uses an account, like a proxy, but the account owner does not want them getting into sections protected with the built-in 2FA via Authenticator App.
Thanks.
Upvotes: 2
Views: 4992
Reputation: 31
Looking at the source code, Fortify actually uses PragmaRX\Google2FA so you can instantiate it directly.
So you can manually generate the current OTP that the authenticator app would generate and do whatever you need to with it. In your case, send an email (or SMS) during the 2FA challenge during login.
use PragmaRX\Google2FA\Google2FA;
...
$currentOTP = app(Google2FA::class)->getCurrentOtp(decrypt($user->two_factor_secret));
Although I think it was an oversight (as well as not requiring confirmation on initial key generation) that they didn't include this functionality built in.
Upvotes: 3