captcha cleanup and added 2fa logins

This commit is contained in:
2024-09-28 11:51:28 +10:00
parent 59ca73519d
commit 538f324ff4
27 changed files with 817 additions and 9005 deletions

View File

@@ -6,10 +6,11 @@ use App\Helpers;
use App\Jobs\SendEmail;
use App\Mail\UserEmailUpdateRequest;
use App\Models\User;
use App\Providers\QRCodeProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use RobThree\Auth\Algorithm;
use RobThree\Auth\TwoFactorAuth;
class AccountController extends Controller
{
@@ -130,4 +131,110 @@ class AccountController extends Controller
session()->flash('message-type', 'success');
return redirect()->route('index');
}
public static function getTFAInstance()
{
$tfa = new TwoFactorAuth(new QRCodeProvider(), 'STEMMechanics', 6, 30, Algorithm::Sha512);
$tfa->ensureCorrectTime();
return $tfa;
}
public function show_tfa()
{
$user = auth()->user();
if ($user->tfa_secret === null) {
$tfa = self::getTFAInstance();
$secret = $tfa->createSecret();
return response()->json([
'secret' => $secret,
]);
} else {
abort(404);
}
}
public function show_tfa_image(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret === null && $request->has('secret')) {
$tfa = self::getTFAInstance();
$qrCodeProvider = new QRCodeProvider();
$qrCode = $qrCodeProvider->getQRCodeImage(
$tfa->getQRText($user->email, $request->get('secret')),
200
);
return response()->stream(function () use ($qrCode) {
echo $qrCode;
}, 200, ['Content-Type' => $qrCodeProvider->getMimeType()]);
} else {
abort(404);
}
}
public function post_tfa(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret === null && $request->has('secret') && $request->has('code')) {
$secret = $request->get('secret');
$code = $request->get('code');
$tfa = self::getTFAInstance();
if ($tfa->verifyCode($secret, $code, 4)) {
$user->tfa_secret = $secret;
$user->save();
$codes = $user->generateBackupCodes();
return response()->json([
'success' => true,
'codes' => $codes
]);
} else {
return response()->json([
'success' => false,
]);
}
} else {
abort(403);
}
}
public function destroy_tfa(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret !== null) {
$user->tfa_secret = null;
$user->save();
$user->backupCodes()->delete();
return response()->json([
'success' => true,
]);
} else {
abort(403);
}
}
public function post_tfa_reset_backup_codes(Request $request)
{
$user = auth()->user();
if ($user->tfa_secret !== null) {
$codes = $user->generateBackupCodes();
return response()->json([
'success' => true,
'codes' => $codes
]);
} else {
abort(403);
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Jobs\SendEmail;
use App\Mail\UserEmailUpdateConfirm;
use App\Mail\UserLogin;
use App\Mail\UserLoginBackupCode;
use App\Mail\UserRegister;
use App\Mail\UserWelcome;
use App\Models\Token;
@@ -47,13 +48,60 @@ class AuthController extends Controller
{
$request->validate([
'email' => 'required|email',
'captcha' => 'required_captcha',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid'),
]);
$forceEmailLogin = false;
if($request->has('code')) {
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
$tfa = AccountController::getTFAInstance();
if ($request->code && $tfa->verifyCode($user->tfa_secret, $request->code, 4)) {
$data = ['url' => session()->pull('url.intended', null)];
return $this->loginByUser($user, $data);
}
}
return view('auth.login-2fa', ['email' => $request->email])->withErrors([
'code' => 'The 2FA code is not valid',
]);
}
if($request->has('backup_code')) {
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
if($user->verifyBackupCode($request->backup_code)) {
$data = ['url' => session()->pull('url.intended', null)];
dispatch(new SendEmail($user->email, new UserLoginBackupCode($user->email)))->onQueue('mail');
return $this->loginByUser($user, $data);
}
}
return view('auth.login-2fa', ['email' => $request->email, 'method' => 'backup'])->withErrors([
'backup_code' => 'The backup code is not valid',
]);
}
if($request->has('method')) {
if($request->get('method') === 'email') {
$forceEmailLogin = true;
} else {
abort(404);
}
}
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
if($user) {
if ($user) {
if (!$forceEmailLogin && $user->tfa_secret !== null) {
return view('auth.login-2fa', ['user' => $user]);
}
$token = $user->tokens()->create([
'type' => 'login',
'data' => ['url' => session()->pull('url.intended', null)],
@@ -190,6 +238,7 @@ class AuthController extends Controller
{
$request->validate([
'email' => 'required|email',
'captcha' => 'required_captcha',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid')

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Spatie\LaravelPdf\Facades\Pdf;
class UserLoginBackupCode extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Hey, did you recently log in?')
->markdown('emails.login-backup-code')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Spatie\LaravelPdf\Facades\Pdf;
class UserLoginTFADisabled extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Two-factor authentication disabled on your account')
->markdown('emails.login-tfa-disabled')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Mail;
use App\Models\Ticket;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Spatie\LaravelPdf\Facades\Pdf;
class UserLoginTFAEnabled extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Two-factor authentication enabled on your account')
->markdown('emails.login-tfa-enabled')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -2,12 +2,16 @@
namespace App\Models;
use App\Jobs\SendEmail;
use App\Mail\UserLoginTFADisabled;
use App\Mail\UserLoginTFAEnabled;
use App\Traits\UUID;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
class User extends Authenticatable implements MustVerifyEmail
{
@@ -36,7 +40,9 @@ class User extends Authenticatable implements MustVerifyEmail
'billing_postcode',
'billing_state',
'billing_country',
'subscribed'
'subscribed',
'tfa_secret',
'agree_tos',
];
/**
@@ -47,6 +53,7 @@ class User extends Authenticatable implements MustVerifyEmail
protected $hidden = [
'password',
'remember_token',
'tfa_secret'
];
/**
@@ -98,6 +105,15 @@ class User extends Authenticatable implements MustVerifyEmail
}
}
}
if ($user->isDirty('tfa_secret')) {
if($user->tfa_secret === null) {
$user->backupCodes()->delete();
dispatch(new SendEmail($user->email, new UserLoginTFADisabled($user->email)))->onQueue('mail');
} else {
dispatch(new SendEmail($user->email, new UserLoginTFAEnabled($user->email)))->onQueue('mail');
}
}
});
static::deleting(function ($user) {
@@ -176,4 +192,38 @@ class User extends Authenticatable implements MustVerifyEmail
{
return $this->admin === 1;
}
public function backupCodes()
{
return $this->hasMany(UserBackupCode::class);
}
public function generateBackupCodes()
{
$this->backupCodes()->delete();
$codes = [];
for ($i = 0; $i < 10; $i++) {
$code = strtoupper(bin2hex(random_bytes(4)));
$codes[] = $code;
UserBackupCode::create([
'user_id' => $this->id,
'code' => $code,
]);
}
return $codes;
}
public function verifyBackupCode($code)
{
$backupCodes = $this->backupCodes()->get();
foreach ($backupCodes as $backupCode) {
if (Hash::check($code, $backupCode->code)) {
$backupCode->delete();
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
class UserBackupCode extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'code'
];
/**
* Set the code attribute and automatically hash the code.
*
* @param string $value
* @return void
*/
public function setCodeAttribute($value)
{
$this->attributes['code'] = Hash::make($value);
}
/**
* Verify the given code against the stored hashed code.
*
* @param string $value
* @return bool
*/
public function verify($value)
{
return Hash::check($value, $this->code);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class CaptchaServiceProvider extends ServiceProvider
{
private string $captchaKey = '6Lc6BIAUAAAAAABZzv6J9ZQ7J9Zzv6J9ZQ7J9Zzv';
private int $timeThreshold = 750;
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Blade::directive('captcha', function () {
return <<<EOT
<input type="text" name="captcha" autocomplete="off" style="position:absolute;left:-9999px;top:-9999px">
<script>
document.addEventListener('DOMContentLoaded', function() {
const errors = {!! json_encode(\$errors->getMessages()) !!};
if(errors && errors.captcha && errors.captcha.length) {
SM.alert('', errors.captcha[0], 'danger');
}
});
</script>
EOT;
});
Blade::directive('captchaScripts', function () {
return <<<EOT
<script>
document.addEventListener('DOMContentLoaded', function() {
window.setTimeout(function() {
const captchaList = document.querySelectorAll('input[name="captcha"]');
captchaList.forEach(function(captcha) {
if(captcha.value === '') {
captcha.value = '$this->captchaKey';
}
});
}, $this->timeThreshold);
});
</script>
EOT;
});
Validator::extend('required_captcha', function ($attribute, $value, $parameters, $validator) {
return $value === $this->captchaKey;
}, 'The form captcha failed to validate. Please try again.');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Providers;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use RobThree\Auth\Providers\Qr\IQRCodeProvider;
class QRCodeProvider implements IQRCodeProvider
{
public function getMimeType(): string
{
return 'image/svg+xml';
}
public function getQRCodeImage(string $qrText, int $size): string
{
$options = new QROptions;
$options->outputBase64 = false;
$options->imageTransparent = true;
return (new QRCode($options))->render($qrText);
}
}