I'm struggling one day on the 2FA implementation. I can get the QR code and I can enable it successfully. The problem is that after my login, the middleware seems bypass the 2FA check and there is no way to input the code.
I used this tutorial to proceed: https://shouts.dev/articles/laravel-two-factor-authentication-with-google-authenticator#step1
This is my routes/web.php file (I deleted all the routes not affected)
//HOME PAGE
Route::any('/', function () {
return redirect('/home');
});
Route::any('home', 'Home@index')->name('home');
//LOGIN & SIGNUP
Route::get("/login", "Authenticate@logIn")->name('login');
Route::post("/login", "Authenticate@logInAction");
Route::get("/forgotpassword", "Authenticate@forgotPassword");
Route::post("/forgotpassword", "Authenticate@forgotPasswordAction");
Route::get("/signup", "Authenticate@signUp");
Route::post("/signup", "Authenticate@signUpAction");
Route::get("/resetpassword", "Authenticate@resetPassword");
Route::post("/resetpassword", "Authenticate@resetPasswordAction");
//LOGOUT
Route::any('logout', function () {
Auth::logout();
return redirect('/login');
});
Route::get('/redirect', 'Auth\LoginController@redirectToProvider');
Route::get('/callback', 'Auth\LoginController@handleProviderCallback');
Route::group(['prefix'=>'2fa'], function(){
Route::get('/','LoginSecurityController@show2faForm');
Route::post('/generateSecret','LoginSecurityController@generate2faSecret')->name('generate2faSecret');
Route::post('/enable2fa','LoginSecurityController@enable2fa')->name('enable2fa');
Route::post('/disable2fa','LoginSecurityController@disable2fa')->name('disable2fa');
// 2fa middleware
Route::post('/2faVerify', function () {
return redirect(URL()->previous());
})->name('2faVerify')->middleware('2fa');
});
// test middleware
Route::get('/test_middleware', function () {
return "2FA middleware work!";
})->middleware(['auth', '2fa']);
This is my application\app\Http\Middleware\LoginSecurityMiddleware.php file
<?php
namespace App\Http\Middleware;
use App\Support\Google2FAAuthenticator;
use Closure;
class LoginSecurityMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$authenticator = app(Google2FAAuthenticator::class)->boot($request);
if ($authenticator->isAuthenticated()) {
return $next($request);
}
return $authenticator->makeRequestOneTimePasswordResponse();
}
}
This is my application\app\Http\Controllers\LoginSecurityController.php file
<?php
namespace App\Http\Controllers;
use App\Models\LoginSecurity;
use Auth;
use Hash;
use Illuminate\Http\Request;
class LoginSecurityController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show 2FA Setting form
*/
public function show2faForm(Request $request){
$user = Auth::user();
$google2fa_url = "";
$secret_key = "";
if($user->loginSecurity()->exists()){
$google2fa = (new \PragmaRX\Google2FAQRCode\Google2FA());
$google2fa_url = $google2fa->getQRCodeInline(
'AgileApp',
$user->email,
$user->loginSecurity->google2fa_secret
);
$secret_key = $user->loginSecurity->google2fa_secret;
}
$data = array(
'user' => $user,
'secret' => $secret_key,
'google2fa_url' => $google2fa_url
);
return view('auth.2fa_settings')->with('data', $data);
}
/**
* Generate 2FA secret key
*/
public function generate2faSecret(Request $request){
$user = Auth::user();
// Initialise the 2FA class
$google2fa = (new \PragmaRX\Google2FAQRCode\Google2FA());
// Add the secret key to the registration data
$login_security = LoginSecurity::firstOrNew(array('user_id' => $user->id));
$login_security->user_id = $user->id;
$login_security->google2fa_enable = 0;
$login_security->google2fa_secret = $google2fa->generateSecretKey();
$login_security->save();
return redirect('/2fa')->with('success',"Secret key is generated.");
}
/**
* Enable 2FA
*/
public function enable2fa(Request $request){
$user = Auth::user();
$google2fa = (new \PragmaRX\Google2FAQRCode\Google2FA());
$secret = $request->input('secret');
$valid = $google2fa->verifyKey($user->loginSecurity->google2fa_secret, $secret);
if($valid){
$user->loginSecurity->google2fa_enable = 1;
$user->loginSecurity->save();
return redirect('2fa')->with('success',"2FA is enabled successfully.");
}else{
return redirect('2fa')->with('error',"Invalid verification Code, Please try again.");
}
}
/**
* Disable 2FA
*/
public function disable2fa(Request $request){
if (!(Hash::check($request->get('current-password'), Auth::user()->password))) {
// The passwords matches
return redirect()->back()->with("error","Your password does not matches with your account password. Please try again.");
}
$validatedData = $request->validate([
'current-password' => 'required',
]);
$user = Auth::user();
$user->loginSecurity->google2fa_enable = 0;
$user->loginSecurity->save();
return redirect('/2fa')->with('success',"2FA is now disabled.");
}
}
This is my application\app\Models\User.php file
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Cache;
use DB;
class User extends Authenticatable {
use Notifiable;
/**
* @primaryKey string - primry key column.
* @dateFormat string - date storage format
* @guarded string - allow mass assignment except specified
* @CREATED_AT string - creation date column
* @UPDATED_AT string - updated date column
*/
protected $primaryKey = 'id';
protected $dateFormat = 'Y-m-d H:i:s';
protected $guarded = ['id', 'type'];
const CREATED_AT = 'created';
const UPDATED_AT = 'updated';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name', 'email', 'password',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password', 'remember_token',
];
public function loginSecurity()
{
//return $this->hasOne(LoginSecurity::class);
return $this->hasOne('App\Models\LoginSecurity');
}
/**
* The tasks that are assigned to the user.
*/
public function role() {
return $this->hasOne('App\Models\Role', 'role_id', 'role_id');
}
/**
* The tasks that are assigned to the user.
*/
public function client() {
return $this->hasOne('App\Models\Client', 'client_id', 'clientid');
}
/**
* relatioship business rules:
* - the User can have many Notes
* - the Note belongs to one User
* - other Note can belong to other tables
*/
public function notes() {
return $this->morphMany('App\Models\Note', 'noteresource');
}
/**
* The tasks that are assigned to the user.
*/
public function assignedTasks() {
return $this->belongsToMany('App\Models\Task', 'tasks_assigned', 'tasksassigned_userid', 'tasksassigned_taskid');
}
/**
* The tasks that are assigned to the user.
*/
public function assignedLeads() {
return $this->belongsToMany('App\Models\Lead', 'leads_assigned', 'leadsassigned_userid', 'leadsassigned_leadid');
}
/**
* The projects that are assigned to the user.
*/
public function assignedProjects() {
return $this->belongsToMany('App\Models\Project', 'projects_assigned', 'projectsassigned_userid', 'projectsassigned_projectid');
}
/**
* users notifications
*/
public function notifications() {
return $this->hasMany('App\Models\EventTracking', 'eventtracking_userid', 'id');
}
/**
* Always encrypt the password before saving to database
*/
public function setFPasswordAttribute($value) {
return bcrypt($value);
}
/**
* count: users projects
* - always reclaulated fresh. No session data here
*/
public function getCountProjectsAttribute() {
}
/**
* count: users unread notifications
* @usage auth()->user()->count_unread_notifications
*/
public function getCountUnreadNotificationsAttribute() {
//use notifications relationship (above)
return $this->notifications->where('eventtracking_status', 'unread')->count();
}
/**
* users full name ucfirst
*/
public function getFullNameAttribute() {
return ucfirst($this->first_name) . ' ' . ucfirst($this->last_name);
}
/**
* get the users avatar. if it does not exist return the default avatar
* @return string
*/
public function getAvatarAttribute() {
return getUsersAvatar($this->avatar_directory, $this->avatar_filename);
}
/**
* check if the user has the role of 'administrator'
* @return bool
*/
public function getIsAdminAttribute() {
if (strtolower($this->role->role_id) == 1) {
return true;
}
return false;
}
/**
* check if the user has the role of 'master'
* @return bool
*/
public function getIsMasterAttribute() {
if (strtolower($this->role->role_id) == 6) {
return true;
}
return false;
}
/**
* check if the user has the type 'client'
* @return bool
*/
public function getIsClientAttribute() {
if (strtolower($this->type) == 'client') {
return true;
}
return false;
}
/**
* check if the user has the type 'client' and also account owner
* @return bool
*/
public function getIsClientOwnerAttribute() {
if (strtolower($this->type) == 'client') {
if ($this->account_owner == 'yes') {
return true;
}
}
return false;
}
/**
* check if the user has the type 'team'
* @return bool
*/
public function getIsTeamAttribute() {
if (strtolower($this->type) == 'team') {
return true;
}
return false;
}
/**
* return 'team' or 'contacts'for use in url's like
* updating user preferences
* @return bool
*/
public function getTeamOrContactAttribute() {
if (strtolower($this->type) == 'team') {
return 'team';
}
return 'contacts';
}
/**
* get the 'name' of this users role
* @return string
*/
public function getUserRoleAttribute() {
return strtolower($this->role->role_name);
}
/**
* format last seen date
* @return string
*/
public function getCarbonLastSeenAttribute() {
if ($this->last_seen == '' || $this->last_seen == null) {
return '---';
}
return \Carbon\Carbon::parse($this->last_seen)->diffForHumans();
}
/**
* is the user online now. Activity is set in the General middle
* [usage] if($user->is_online)
* @return string
*/
public function getisOnlineAttribute() {
return Cache::has('user-is-online-' . $this->id);
}
/**
* get the users preferred left menu position. If none is
* defined, return the default system seting
* @return string
*/
public function getLeftMenuPositionAttribute() {
//none logged in users
if (!auth()->check()) {
return config('system.settings_system_default_leftmenu');
}
//logged in user
if ($this->pref_leftmenu_position != '') {
return $this->pref_leftmenu_position;
} else {
return config('system.settings_system_default_leftmenu');
}
}
/**
* get the users preferred stats panels position. If none is
* defined, return the default system seting
* @return string
*/
public function getStatsPanelPositionAttribute() {
//none logged in users
if (!auth()->check()) {
return config('system.settings_system_default_statspanel');
}
//logged in user
if ($this->pref_statspanel_position != '') {
return $this->pref_statspanel_position;
} else {
return config('system.settings_system_default_statspanel');
}
}
/**
* check if the user has any permissions to add content, so that we can display the red add button in top nav
* @return bool
*/
public function getCanAddContentAttribute() {
$count = 0;
//add
$count += ($this->role->role_clients >= 2) ? 1 : 0;
$count += ($this->role->role_contacts >= 2) ? 1 : 0;
$count += ($this->role->role_invoices >= 2) ? 1 : 0;
$count += ($this->role->role_estimates >= 2) ? 1 : 0;
$count += ($this->role->role_items >= 2) ? 1 : 0;
$count += ($this->role->role_tasks >= 2) ? 1 : 0;
$count += ($this->role->role_projects >= 2) ? 1 : 0;
$count += ($this->role->role_leads >= 2) ? 1 : 0;
$count += ($this->role->role_expenses >= 2) ? 1 : 0;
$count += ($this->role->role_team >= 2) ? 1 : 0;
$count += ($this->role->role_tickets >= 2) ? 1 : 0;
$count += ($this->role->role_knowledgebase >= 2) ? 1 : 0;
return ($count > 0) ? true : false;
}
/**
* Query Scope
* Left join the client table (used if needed)
* @param object $query automatically passed by eloquent
* @return bool
*/
public function scopeLeftjoinClients($query) {
$query->leftJoin('clients', function ($leftJoin) {
$leftJoin->on('clients.client_id', '=', 'users.clientid');
});
}
}
This is my application\app\Support\Google2FAAuthenticator.php file
<?php
namespace App\Support;
use PragmaRX\Google2FALaravel\Support\Authenticator;
class Google2FAAuthenticator extends Authenticator
{
protected function canPassWithoutCheckingOTP()
{
if ($this->getUser()->loginSecurity == null)
return true;
return
!$this->getUser()->loginSecurity->google2fa_enable ||
!$this->isEnabled() ||
$this->noUserIsAuthenticated() ||
$this->twoFactorAuthStillValid();
}
protected function getGoogle2FASecretKey()
{
$secret = $this->getUser()->loginSecurity->{$this->config('otp_secret_column')};
if (is_null($secret) || empty($secret)) {
throw new InvalidSecretKey('Secret key cannot be empty.');
}
return $secret;
}
}
This is my application\app\Models\LoginSecurity.php file
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class LoginSecurity extends Model
{
use HasFactory;
protected $fillable = [
'user_id'
];
public function user()
{
//return $this->belongsTo(User::class);
return $this->belongsTo('App\Models\User');
}
}
This is my application\config\google2fa.php file
<?php
return [
/*
* Enable / disable Google2FA.
*/
'enabled' => env('OTP_ENABLED', true),
/*
* Lifetime in minutes.
*
* In case you need your users to be asked for a new one time passwords from time to time.
*/
'lifetime' => env('OTP_LIFETIME', 0), // 0 = eternal
/*
* Renew lifetime at every new request.
*/
'keep_alive' => env('OTP_KEEP_ALIVE', true),
/*
* Auth container binding.
*/
'auth' => 'auth',
/*
* Guard.
*/
'guard' => '',
/*
* 2FA verified session var.
*/
'session_var' => 'google2fa',
/*
* One Time Password request input name.
*/
'otp_input' => 'one_time_password',
/*
* One Time Password Window.
*/
'window' => 1,
/*
* Forbid user to reuse One Time Passwords.
*/
'forbid_old_passwords' => false,
/*
* User's table column for google2fa secret.
*/
'otp_secret_column' => 'google2fa_secret',
/*
* One Time Password View.
*/
'view' => 'auth.2fa_verify',
/*
* One Time Password error message.
*/
'error_messages' => [
'wrong_otp' => "The 'One Time Password' typed was wrong.",
'cannot_be_empty' => 'One Time Password cannot be empty.',
'unknown' => 'An unknown error has occurred. Please try again.',
],
/*
* Throw exceptions or just fire events?
*/
'throw_exceptions' => env('OTP_THROW_EXCEPTION', true),
/*
* Which image backend to use for generating QR codes?
*
* Supports imagemagick, svg and eps
*/
'qrcode_image_backend' => \PragmaRX\Google2FALaravel\Support\Constants::QRCODE_IMAGE_BACKEND_SVG,
];
This is the code I added on my application\app\Http\Kernel.php
protected $routeMiddleware = [
'2fa' => \App\Http\Middleware\LoginSecurityMiddleware::class,
And this is my migration file
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateLoginSecuritiesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('login_securities', function (Blueprint $table) {
$table->id();
$table->integer('user_id');
$table->boolean('google2fa_enable')->default(false);
$table->string('google2fa_secret')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('login_securities');
}
}
The system generate correctly the QR, I can add the QR to Google Authenticator and I can receive the code confirmation. After login, nothing happen.