Skip to content

Commit

Permalink
Allow multiple devices
Browse files Browse the repository at this point in the history
  • Loading branch information
maurohmartinez committed Oct 17, 2023
1 parent 767228c commit abd6e19
Show file tree
Hide file tree
Showing 14 changed files with 223 additions and 60 deletions.
29 changes: 24 additions & 5 deletions src/TwoFactorAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@

namespace MHMartinez\TwoFactorAuth;

use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
use Exception;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Session;
use MHMartinez\TwoFactorAuth\app\Models\TwoFactorAuth as TwoFactorAuthModel;
use MHMartinez\TwoFactorAuth\app\Notifications\ResetTwoFactorAuth;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
Expand Down Expand Up @@ -42,12 +45,13 @@ public function generateUserSecretKey(): string
public function generateQR(string $userSecret): string
{
$google2FA = app(Google2FA::class);
$google2FA->setQrcodeService(new Bacon(new SvgImageBackEnd()));
$google2FA->setQrcodeService(new Bacon(new ImagickImageBackEnd()));

return $google2FA->getQRCodeInline(
config('app.name'),
Auth::guard(config('two_factor_auth.guard'))->user()->getAttribute('email'),
$userSecret,
500,
);
}

Expand All @@ -56,7 +60,7 @@ public function getUserSecretKey(): ?string
/** @var TwoFactorAuthModel $secret */
$secret = $this->getUserTwoFactorAuthSecret(Auth::guard(config('two_factor_auth.guard'))->user());

return $secret ? decrypt($secret->secret) : null;
return $secret?->secret;
}

public function getOneTimePasswordRequestField(): ?string
Expand All @@ -82,6 +86,21 @@ public function handleRemember(): void
Session::remove(config('two_factor_auth.user_secret_key'));
}

public function sendSetupEmail(Authenticatable $user): bool
{
try {
$token = $this->getUserTwoFactorAuthSecret($user)?->getRawOriginal('secret') ?? $this->generateUserSecretKey();
$notification = new ResetTwoFactorAuth($token);
$user->notify($notification);
} catch (Exception $e) {
Log::error($e);

return false;
}

return true;
}

public function getUserTwoFactorAuthSecret(?Authenticatable $user): Builder|Model|null
{
return !$user
Expand All @@ -91,11 +110,11 @@ public function getUserTwoFactorAuthSecret(?Authenticatable $user): Builder|Mode
->first();
}

public function updateOrCreateUserSecret(string $userSecret)
public function updateOrCreateUserSecret(string $userSecret): void
{
TwoFactorAuthModel::updateOrCreate(
['user_id' => Auth::guard(config('two_factor_auth.guard'))->user()->id],
['secret' => $userSecret],
);
}
}
}
61 changes: 42 additions & 19 deletions src/app/Http/Controllers/TwoFactorAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,74 @@

namespace MHMartinez\TwoFactorAuth\app\Http\Controllers;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use \MHMartinez\TwoFactorAuth\app\Models\TwoFactorAuth as ModelTwoFactorAuth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Session;
use JetBrains\PhpStorm\NoReturn;
use MHMartinez\TwoFactorAuth\TwoFactorAuth;
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
use PragmaRX\Google2FA\Exceptions\InvalidCharactersException;
use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException;
use PragmaRX\Google2FALaravel\Google2FA;
use PragmaRX\Google2FAQRCode\Exceptions\MissingQrCodeServiceException;

class TwoFactorAuthController extends Controller
{
public function __construct(private Google2FA $google2FA, private TwoFactorAuth $twoFactorAuth)
public function __construct(private readonly Google2FA $google2FA, private readonly TwoFactorAuth $twoFactorAuth)
{
}

/**
* @throws IncompatibleWithGoogleAuthenticatorException|MissingQrCodeServiceException
* @throws InvalidCharactersException|SecretKeyTooShortException
*/
#[NoReturn] public function setupTwoFactorAuth(): Factory|View|Application|RedirectResponse
public function sendSetupEmail(): View
{
if (!Auth::guard(config('two_factor_auth.guard'))->user()) {
return Redirect::to(url('/'));
$user = Auth::guard(config('two_factor_auth.guard'))->user();
$emailSent = $this->twoFactorAuth->sendSetupEmail($user);

return view('two_factor_auth::send_setup_email', ['emailSent' => $emailSent]);
}

public function setupWithQr(string $token): View|RedirectResponse
{
// Users can only set a 2FA from a link sent by email
$tokenSecret = ModelTwoFactorAuth::query()
->where('secret', $token)
->first();

// If no token or user found, the token probably expired, abort!
if (!$tokenSecret || !$tokenSecret->user) {
abort(404);
}

$userSecret = $this->twoFactorAuth->generateUserSecretKey();
$QR_Image = $this->twoFactorAuth->generateQR($userSecret);
// Login in the user
Auth::guard(config('two_factor_auth.guard'))->login($tokenSecret->user);

// Get token and build qr
$userSecretToken = $tokenSecret->secret;
$qr = $this->twoFactorAuth->generateQR($userSecretToken);

return view('two_factor_auth::setup', ['QR_Image' => $QR_Image, 'secret' => $userSecret]);
return view('two_factor_auth::setup', ['qr' => $qr, 'secret' => $userSecretToken]);
}

#[NoReturn] public function validateTwoFactorAuth(): Factory|View|Application|RedirectResponse
public function validateTokenWithForm(): View|RedirectResponse
{
if (!Auth::guard(config('two_factor_auth.guard'))->user()) {
if (!$this->isUserLogged()) {
return Redirect::to(url('/'));
}

// If the user doesn't have any device setup, redirect
$user = Auth::guard(config('two_factor_auth.guard'))->user();
$token = app(TwoFactorAuth::class)->getUserTwoFactorAuthSecret($user);
if (!$token) {
return Redirect::route('two_factor_auth.validate')->with('', '');
}

return view('two_factor_auth::validate', ['secret' => $this->twoFactorAuth->getUserSecretKey()]);
}

/**
* @throws IncompatibleWithGoogleAuthenticatorException|SecretKeyTooShortException|InvalidCharactersException
*/
#[NoReturn] public function authenticateTwoFactorAuth(): RedirectResponse
public function authenticatePost(): RedirectResponse
{
if (!Auth::guard(config('two_factor_auth.guard'))->user()) {
return Redirect::to(url('/'));
Expand All @@ -63,7 +81,7 @@ public function __construct(private Google2FA $google2FA, private TwoFactorAuth
if (!$oneTimePass || !$userSecret || !$this->google2FA->verifyKey($userSecret, $oneTimePass)) {
Session::put(config('two_factor_auth.user_secret_key'), $userSecret);

return Redirect::back()->withErrors(['error' => __('two_factor_auth::form.error_msg')]);
return Redirect::back()->withErrors(['error' => __('two_factor_auth::messages.error_msg')]);
}

$this->twoFactorAuth->updateOrCreateUserSecret($userSecret);
Expand All @@ -73,4 +91,9 @@ public function __construct(private Google2FA $google2FA, private TwoFactorAuth
return Redirect::route(config('two_factor_auth.route_after_validation'));

}

private function isUserLogged(): bool
{
return Auth::guard(config('two_factor_auth.guard'))->check();
}
}
9 changes: 2 additions & 7 deletions src/app/Http/Middleware/TwoFactorAuthMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,12 @@ public function handle(Request $request, Closure $next): mixed

if (Auth::guard(config('two_factor_auth.guard'))->check()) {
$user = Auth::guard(config('two_factor_auth.guard'))->user();

if (!$user) {
return $next($request);
}

if ($user instanceof TwoFactorAuthInterface && !$user->shouldValidateWithTwoFactorAuth()) {
if (!$user || $user instanceof TwoFactorAuthInterface && !$user->shouldValidateWithTwoFactorAuth()) {
return $next($request);
}

if (!app(TwoFactorAuth::class)->getUserTwoFactorAuthSecret($user)) {
return Redirect::route('two_factor_auth.setup');
return Redirect::route('two_factor_auth.send_setup_email');
}

if (!$google2FA->isAuthenticated()) {
Expand Down
22 changes: 18 additions & 4 deletions src/app/Models/TwoFactorAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,36 @@

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Casts\Attribute;

/**
* App\Models\AlertAdmin
*
* @property int $id
* @property int $user_id
* @property string $secret
* @property Carbon $updated_at
*/
class TwoFactorAuth extends Model
{
use Notifiable;

public string $email;

protected $table = 'two_factor_auth';

protected $fillable = ['user_id', 'secret'];

public function setSecretAttribute(string $value): void
protected function secret(): Attribute
{
return Attribute::make(
get: fn (string $value) => decrypt($value),
set: fn (string $value) => encrypt($value),
);
}
public function user(): BelongsTo
{
$this->attributes['secret'] = encrypt($value);
return $this->belongsTo(config('two_factor_auth.user_model'));
}
}
}
36 changes: 36 additions & 0 deletions src/app/Notifications/ResetTwoFactorAuth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace MHMartinez\TwoFactorAuth\app\Notifications;

use Closure;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\HtmlString;

class ResetTwoFactorAuth extends Notification
{
public static ?Closure $toMailCallback;

public function __construct(private readonly string $token)
{
}

public function via(): array
{
return ['mail'];
}

public function toMail(): MailMessage
{
return (new MailMessage)
->subject('2FA ' . config('app.name'))
->greeting(__('two_factor_auth::messages.email_hi'))
->line(__('two_factor_auth::messages.email_text'))
->action(__('two_factor_auth::messages.email_btn_action'), route('two_factor_auth.setup', ['token' => $this->token]));
}

public static function toMailUsing($callback): void
{
static::$toMailCallback = $callback;
}
}
5 changes: 5 additions & 0 deletions src/config/two_factor_auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,9 @@
* Customize the blade @extends() for the views
*/
'layout' => 'layouts.app',

/*
* Customize the user model
*/
'user_model' => \App\Models\User::class,
];
5 changes: 2 additions & 3 deletions src/resources/lang/en/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
'warning' => 'This site requires two-factor authentication to access this site. You can choose Google Authenticator, LassPass Authenticator, One Password, or Microsoft Authenticator. ',
'description' => 'Configure your two-factor authentication by scanning the QR code. You can also manually enter the following code into your app:',
'validate_title' => 'Validate with Two-Factor Authentication',
'revalidate_description' => 'Click the following link to reset your Two-Factor Authentication App:',
'revalidate_description' => 'If you lost access to your Two-Factor Authentication App, click the following link to reset it:',
're_setup_btn' => 'Reset',
'error_msg' => 'Invalid code. Please try again.',
'validate_btn' => 'Validate',
'enter_code_placeholder' => 'Enter code...',
];
];
10 changes: 10 additions & 0 deletions src/resources/lang/en/messages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

return [
'error_email_not_sent' => 'An error ocurred when trying to send you an email. Please try again by reloading this page.',
'error_msg' => 'Invalid code. Please try again.',
'setup_email_sent' => 'We have sent you an email to setup your 2FA account.',
'email_hi' => 'Hi!',
'email_text' => 'You have requested to link another device to your 2FA protected account.',
'email_btn_action' => 'Add device for 2FA',
];
5 changes: 2 additions & 3 deletions src/resources/lang/es/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
'warning' => 'Este sitio requiere una autenticación de dos factores para acceder a él. Puede elegir Google Authenticator, LassPass Authenticator, One Password o Microsoft Authenticator.',
'description' => 'Configure su autenticación de dos factores escaneando el código QR. También puedes introducir manualmente el siguiente código en tu aplicación:',
'validate_title' => 'Validación con autenticación de dos factores',
'revalidate_description' => 'Haga clic en el siguiente enlace para restablecer su aplicación de autenticación de dos factores:',
'revalidate_description' => 'Si perdió acceso a su app y su autenticación, haga clic en el siguiente enlace para restablecerlo:',
're_setup_btn' => 'Reconfigurar',
'error_msg' => 'Código inválido. Por favor, inténtelo de nuevo.',
'validate_btn' => 'Validar',
'enter_code_placeholder' => 'Ingrese el código...',
];
];
10 changes: 10 additions & 0 deletions src/resources/lang/es/messages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

return [
'error_email_not_sent' => 'Ocurrió un error al enviarle un email. Por favor intente nuevamente regargando esta página.',
'error_msg' => 'Código inválido. Por favor, inténtelo de nuevo.',
'setup_email_sent' => 'Te enviamos un email para configurar tu 2FA.',
'email_hi' => 'Hola!',
'email_text' => 'Ha solicitado vincular un dispositivo a su cuenta protegida por 2FA.',
'email_btn_action' => 'Add device for 2FA',
];
35 changes: 35 additions & 0 deletions src/resources/views/send_setup_email.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@extends(\Illuminate\Support\Facades\Config::get('two_factor_auth.layout'))

<section class="bg-light" style="height: 100vh;">
<div class="container py-5 h-100">
<div class="row d-md-flex justify-content-center align-items-center h-100">
<div class="col-xl-10">
<div class="card rounded-3 bg-white">
<div class="row g-0">
<div class="card-body p-md-5 mx-md-4">
<div class="text-center">
<img src="{{ asset('vendor/two_factor_auth/shield.png') }}" alt="Shield" style="max-width: 100px; height: auto;">
<h4 class="mt-1 pb-1">{{ __('two_factor_auth::form.setup_title') }}</h4>
</div>
@if($emailSent)
<div class="alert alert-success text-center">
<p class="mb-0">{{ __('two_factor_auth::messages.setup_email_sent') }}</p>
</div>
@else
<div class="alert alert-danger text-center">
<p class="mb-0">{{ __('two_factor_auth::messages.error_email_not_sent') }}</p>
</div>
@endif
@if(__('two_factor_auth::form.warning'))
<hr>
<div class="alert alert-warning text-center">
<small class="text-center">{{ __('two_factor_auth::form.warning') }}</small>
</div>
@endif
</div>
</div>
</div>
</div>
</div>
</div>
</section>
Loading

0 comments on commit abd6e19

Please sign in to comment.