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\Jobs\SendEmail;
use App\Mail\UserEmailUpdateRequest; use App\Mail\UserEmailUpdateRequest;
use App\Models\User; use App\Models\User;
use App\Providers\QRCodeProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use RobThree\Auth\Algorithm;
use Illuminate\Validation\Rule; use RobThree\Auth\TwoFactorAuth;
class AccountController extends Controller class AccountController extends Controller
{ {
@@ -130,4 +131,110 @@ class AccountController extends Controller
session()->flash('message-type', 'success'); session()->flash('message-type', 'success');
return redirect()->route('index'); 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\Jobs\SendEmail;
use App\Mail\UserEmailUpdateConfirm; use App\Mail\UserEmailUpdateConfirm;
use App\Mail\UserLogin; use App\Mail\UserLogin;
use App\Mail\UserLoginBackupCode;
use App\Mail\UserRegister; use App\Mail\UserRegister;
use App\Mail\UserWelcome; use App\Mail\UserWelcome;
use App\Models\Token; use App\Models\Token;
@@ -47,13 +48,60 @@ class AuthController extends Controller
{ {
$request->validate([ $request->validate([
'email' => 'required|email', 'email' => 'required|email',
'captcha' => 'required_captcha',
], [ ], [
'email.required' => __('validation.custom_messages.email_required'), 'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid'), '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(); $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([ $token = $user->tokens()->create([
'type' => 'login', 'type' => 'login',
'data' => ['url' => session()->pull('url.intended', null)], 'data' => ['url' => session()->pull('url.intended', null)],
@@ -190,6 +238,7 @@ class AuthController extends Controller
{ {
$request->validate([ $request->validate([
'email' => 'required|email', 'email' => 'required|email',
'captcha' => 'required_captcha',
], [ ], [
'email.required' => __('validation.custom_messages.email_required'), 'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid') '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; namespace App\Models;
use App\Jobs\SendEmail;
use App\Mail\UserLoginTFADisabled;
use App\Mail\UserLoginTFAEnabled;
use App\Traits\UUID; use App\Traits\UUID;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Hash;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail
{ {
@@ -36,7 +40,9 @@ class User extends Authenticatable implements MustVerifyEmail
'billing_postcode', 'billing_postcode',
'billing_state', 'billing_state',
'billing_country', 'billing_country',
'subscribed' 'subscribed',
'tfa_secret',
'agree_tos',
]; ];
/** /**
@@ -47,6 +53,7 @@ class User extends Authenticatable implements MustVerifyEmail
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', '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) { static::deleting(function ($user) {
@@ -176,4 +192,38 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
return $this->admin === 1; 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);
}
}

View File

@@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\CaptchaServiceProvider::class,
]; ];

View File

@@ -6,13 +6,15 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-imagick": "*",
"chillerlan/php-qrcode": "^5.0",
"gehrisandro/tailwind-merge-laravel": "^1.2", "gehrisandro/tailwind-merge-laravel": "^1.2",
"intervention/image": "^3.5", "intervention/image": "^3.5",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"livewire/livewire": "^3.4", "livewire/livewire": "^3.4",
"php-ffmpeg/php-ffmpeg": "^1.2", "php-ffmpeg/php-ffmpeg": "^1.2",
"ext-imagick": "*" "robthree/twofactorauth": "^3.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

8994
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('tfa_secret')->nullable();
$table->boolean('agree_tos')->default(false);
});
DB::table('users')->update(['agree_tos' => true]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('agree_tos');
$table->dropColumn('tfa_secret');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_backup_codes', function (Blueprint $table) {
$table->id();
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
$table->string('code', 256);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_backup_codes');
}
};

View File

@@ -16,6 +16,23 @@ let SM = {
Swal.fire(data); Swal.fire(data);
}, },
confirm: (title, content, button, callback) => {
Swal.fire({
position: 'top',
icon: 'warning',
iconColor: '#b91c1c',
title: title,
html: content,
showCancelButton: true,
confirmButtonText: button,
confirmButtonColor: '#b91c1c',
cancelButtonText: 'Cancel',
reverseButtons: true
}).then((result) => {
callback(result.isConfirmed);
});
},
copyToClipboard: (text) => { copyToClipboard: (text) => {
const copyContent = async () => { const copyContent = async () => {
try { try {

View File

@@ -42,6 +42,79 @@ $shipping_same_billing = $user->shipping_address === $user->billing_address
</div> </div>
</section> </section>
<section x-data="{ open: false }">
<a href="#" class="flex items-center" @click.prevent="open = !open">
<i :class="{'transform': !open, '-rotate-90': !open, 'translate-y-0.5': true}"
class="fa-solid fa-angle-down text-lg transition-transform mr-2"></i>
<h3 class="text-lg font-bold mt-4 mb-3">Two Factor Authentication</h3>
</a>
<div class="px-4 mb-4" x-show="open">
<div class="flex items-center border border-gray-300 rounded bg-white pl-2 pr-4 py-3 mb-4">
<div class="bg-gray-200 rounded-full w-14 h-14 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-envelope text-2xl"></i>
</div>
<div class="mx-4 flex-grow">
<p class="flex mb-2">
<span class="text-sm font-bold mr-2">Use Email</span>
<span class="text-xs bg-green-500 text-white rounded px-2 py-0.5">Enabled</span>
</p>
<p class="text-xs">Use the security link sent to your email address as your two-factor authentication (2FA). The security link will be sent to the address associated with your account.</p>
</div>
</div>
<div class="border border-gray-300 rounded bg-white pl-2 pr-4 py-3">
<div class="flex items-center">
<div class="bg-gray-200 rounded-full w-14 h-14 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-mobile-screen-button text-2xl"></i>
</div>
<div class="mx-4 flex-grow">
<p class="flex mb-2">
<span class="text-sm font-bold mr-2">Use Authenticator App</span>
<span x-cloak x-show="!$store.tfa.enabled" class="text-xs bg-red-500 text-white rounded px-2 py-0.5">Disabled</span>
<span x-cloak x-show="$store.tfa.enabled" class="text-xs bg-green-500 text-white rounded px-2 py-0.5">Enabled</span>
</p>
<p class="text-xs">Use an Authenticator App as your two-factor authenticator. When you sign in you'll be asked to use the security code provided by your Authenticator App.</p>
</div>
<div class="flex flex-col text-nowrap gap-2">
<x-ui.button x-show="!$store.tfa.enabled" id="tfa_button" type="button" color="primary-outline" x-data x-on:click.prevent="setupTFA()">Setup</x-ui.button>
<x-ui.button x-show="$store.tfa.enabled" type="button" color="danger-outline" x-data x-on:click.prevent="destroyTFA()">Disable</x-ui.button>
<a href="#" x-show="$store.tfa.enabled" x-on:click.prevent="resetBackupCodes($event)" class="text-xs link">Reset Backup Codes</a>
</div>
</div>
<div class="mt-4 pt-4 border-t flex items-center justify-center gap-4" x-cloak x-show="$store.tfa.show && !$store.tfa.loading">
<img src="/loading.gif" id="tfa_image_loader" alt="loading" width="100" height="100"/>
<img src="" id="tfa_image" alt="QR Code" width="150" height="150" style="display:none" onload="this.style.display='block';document.getElementById('tfa_image_loader').style.display='none';"/>
<div>
<p class="text-xs mb-2">Scan the QR Code into your Authenticator App and enter the code provided below</p>
<div class="flex items-center gap-4 justify-center">
<x-ui.input name="code" id="code" class="mb-0" />
<x-ui.button class="mt-1" type="button" color="primary-outline" x-on:click.prevent="linkTFA()">Link</x-ui.button>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t flex justify-center" x-cloak x-show="$store.tfa.loading">
<img src="/loading.gif" alt="loading" width="100" height="100"/>
</div>
<div class="mt-4 pt-4 border-t flex justify-center" x-cloak x-show="$store.tfa.codes && !$store.tfa.loading">
<div class="w-[34rem] flex items-center gap-4">
<div class="w-[18rem] mx-auto">
<p class="text-sm font-bold mb-1">Save your Backup Codes</p>
<ul class="ml-6 mb-4 text-xs list-disc">
<li>Keep these backup codes safe</li>
<li>You can only use each one once</li>
<li>They will not be shown again</li>
<li>Any existing codes can no longer be used</li>
</ul>
</div>
<div class="w-[16rem] bg-gray-200 p-4 text-sm font-mono flex flex-wrap justify-center">
<template x-for="(code, idx) in $store.tfa.codes" :key="idx">
<p class="mx-4" x-text="code"></p>
</template>
</div>
</div>
</div>
</div>
</div>
</section>
<section x-data="{ open: true }"> <section x-data="{ open: true }">
<a href="#" class="flex items-center" @click.prevent="open = !open"> <a href="#" class="flex items-center" @click.prevent="open = !open">
@@ -97,3 +170,106 @@ $shipping_same_billing = $user->shipping_address === $user->billing_address
</form> </form>
</x-container> </x-container>
</x-layout> </x-layout>
{{ $codes ?? '' }}
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('tfa', {
show: false,
secret: null,
enabled: {{ $user->tfa_secret !== null ? 'true' : 'false'}},
codes: null,
loading: false
});
});
function setupTFA() {
document.getElementById('tfa_button').disabled = true;
axios.get('/account/2fa')
.then(response => {
if(response.data.secret) {
Alpine.store('tfa').show = true;
Alpine.store('tfa').secret = response.data.secret;
document.getElementById('tfa_image').src = '/account/2fa/image?secret=' + response.data.secret;
} else {
SM.alert('2FA Error', 'An error occurred while setting up two-factor authentication. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while setting up two-factor authentication. Please try again later', 'danger');
});
}
function linkTFA() {
Alpine.store('tfa').loading = true;
axios.post('/account/2fa', {
code: document.getElementById('code').value,
secret: Alpine.store('tfa').secret,
})
.then(response => {
console.log(response.data);
if(response.data.success) {
SM.alert('2FA Linked', 'Two-factor authentication has been successfully linked to your account', 'success');
document.getElementById('tfa_button').disabled = false;
document.getElementById('code').value = '';
document.getElementById('tfa_image').src = '';
Alpine.store('tfa').show = false;
Alpine.store('tfa').enabled = true;
Alpine.store('tfa').codes = response.data.codes;
} else {
SM.alert('2FA Error', 'An error occurred while linking two-factor authentication. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while linking two-factor authentication. Please try again later', 'danger');
})
.finally(() => {
Alpine.store('tfa').loading = false;
});
}
function resetBackupCodes(event) {
event.target.classList.add('disabled');
Alpine.store('tfa').codes = null;
Alpine.store('tfa').loading = true;
axios.post('/account/2fa/reset-backup-codes')
.then(response => {
if(response.data.success) {
Alpine.store('tfa').codes = response.data.codes;
} else {
SM.alert('2FA Error', 'An error occurred while resetting your backup codes. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while resetting your backup codes. Please try again later', 'danger');
})
.finally(() => {
event.target.classList.remove('disabled');
Alpine.store('tfa').loading = false;
});
}
function destroyTFA() {
SM.confirm('Disable 2FA', 'Are you sure you want to remove two-factor authentication from your account?', 'Disable', (confirm) => {
if(confirm) {
axios.delete('/account/2fa')
.then(response => {
if (response.data.success) {
SM.alert('2FA Disabled', 'Two-factor authentication has been successfully disabled on your account', 'success');
Alpine.store('tfa').enabled = false;
Alpine.store('tfa').codes = null;
} else {
SM.alert('2FA Error', 'An error occurred while disabling two-factor authentication. Please try again later', 'danger');
}
})
.catch(() => {
SM.alert('2FA Error', 'An error occurred while disabling two-factor authentication. Please try again later', 'danger');
});
}
}, {
confirmButtonText: 'Disable'
});
}
</script>

View File

@@ -0,0 +1,68 @@
@php
if(!isset($email)) {
$email = '';
if(isset($user)) {
$email = $user->email;
}
}
@endphp
<x-layout :bodyClass="'image-background'">
<div x-data="{show:'{{ $method ?? 'tfa' }}'}">
<x-dialog x-cloak x-show="show==='tfa'" formaction="{{ route('login.store') }}">
<x-slot:title>
<a class="link absolute left-0" href="{{ route('login') }}"><i class="fa-solid fa-angle-left"></i></a>
Please enter 2FA code
</x-slot:title>
<x-slot:header>
<p class="text-sm">Two-factor authentication (2FA) is enabled for your account. Please enter a code to log in.</p>
</x-slot:header>
<input type="hidden" name="email" value="{{ $email }}"/>
<x-ui.input type="text" name="code" label="Code" floating autofocus error="{{ $errors->first('code') }}"/>
<x-slot:footer>
<div class="text-xs">
Having trouble? <a class="link" href="#" x-on:click.prevent="show='other'">Sign in another way</a>
</div>
<x-ui.button type="submit">Verify</x-ui.button>
</x-slot:footer>
</x-dialog>
<x-dialog x-cloak x-show="show==='other'">
@captcha
<x-slot:title>
<a class="link absolute left-0" href="#" x-on:click.prevent="show='tfa'"><i class="fa-solid fa-angle-left"></i></a>
Sign in another way
</x-slot:title>
<x-slot:header>Select the method to sign in to your account</x-slot:header>
<div class="flex flex-col gap-4 mb-4">
<form method="post" action="{{ route('login.store') }}">
@csrf
@captcha
<input type="hidden" name="email" value="{{ $email }}" />
<input type="hidden" name="method" value="email" />
<x-ui.button type="submit" class="w-full">Email Link</x-ui.button>
</form>
<x-ui.button type="button" x-on:click.prevent="show='backup'">Enter Backup Code</x-ui.button>
</div>
<x-slot:footer>
<div class="text-xs">If you need support for accessing your account, please contact STEMMechanics support at <a href="mailto:hello@stemmechanics.com.au" class="link">hello@stemmechanics.com.au</a></div>
</x-slot:footer>
</x-dialog>
<x-dialog x-cloak x-show="show==='backup'" formaction="{{ route('login.store') }}">
<x-slot:title>
<a class="link absolute left-0" href="#" x-on:click.prevent="show='other'"><i class="fa-solid fa-angle-left"></i></a>
Please enter a backup code
</x-slot:title>
<x-slot:header>
<p class="text-sm">Enter one of your backup codes below to log in. Once a backup codes are a 1 time use only.</p>
</x-slot:header>
@captcha
<input type="hidden" name="email" value="{{ $email }}"/>
<x-ui.input type="text" name="backup_code" label="Backup Code" floating autofocus error="{{ $errors->first('backup_code') }}" />
<x-slot:footer center>
<x-ui.button class="self-end" type="submit">Verify</x-ui.button>
</x-slot:footer>
</x-dialog>
</div>
</x-layout>

View File

@@ -0,0 +1,14 @@
<x-layout :bodyClass="'image-background'">
<x-dialog formaction="{{ route('login.store') }}">
@captcha
<x-slot:title>Sign in another way</x-slot:title>
<x-slot:header>Select the method to sign in to your account</x-slot:header>
<div class="flex flex-col gap-4 mb-4">
<x-ui.button type="button" onclick="loginUsingEmail()">Email Link</x-ui.button>
<x-ui.button type="button" onclick="loginUsingEmail()">Enter Backup Code</x-ui.button>
</div>
<x-slot:footer>
<div class="text-xs">If you need support for accessing your account, please contact STEMMechanics support at <a href="mailto:hello@stemmechanics.com.au" class="link">hello@stemmechanics.com.au</a></div>
</x-slot:footer>
</x-dialog>
</x-layout>

View File

@@ -1,5 +1,6 @@
<x-layout :bodyClass="'image-background'"> <x-layout :bodyClass="'image-background'">
<x-dialog formaction="{{ route('login.store') }}"> <x-dialog formaction="{{ route('login.store') }}">
@captcha
@if(session('status') == 'not-found') @if(session('status') == 'not-found')
<x-slot:title>Sorry, we didn't recognize that email</x-slot:title> <x-slot:title>Sorry, we didn't recognize that email</x-slot:title>
<x-slot:header> <x-slot:header>
@@ -8,7 +9,7 @@
@else @else
<x-slot:title>Sign in with email</x-slot:title> <x-slot:title>Sign in with email</x-slot:title>
<x-slot:header> <x-slot:header>
<p>Enter the email address associated with your account, and we'll send a magic link to your inbox.</p> <p>Enter the email address associated with your account</p>
</x-slot:header> </x-slot:header>
@endif @endif
<x-ui.input type="email" name="email" label="Email" floating autofocus /> <x-ui.input type="email" name="email" label="Email" floating autofocus />

View File

@@ -1,7 +1,7 @@
<div class="flex items-center justify-center flex-grow py-24"> <div class="flex items-center justify-center flex-grow py-24" {{ $attributes }}>
<div class="w-full mx-2 max-w-lg p-8 pb-6 bg-white rounded-md shadow-deep"> <div class="w-full mx-2 max-w-lg p-8 pb-6 bg-white rounded-md shadow-deep">
@isset($title) @isset($title)
<h2 class="text-2xl font-bold mb-4 text-center">{{ $title }}</h2> <h2 class="text-2xl font-bold mb-4 text-center relative">{{ $title }}</h2>
@endisset @endisset
@isset($header) @isset($header)
<div class="flex items-center gap-4 mb-4"> <div class="flex items-center gap-4 mb-4">

View File

@@ -31,6 +31,7 @@
</script> </script>
@endif @endif
@stack('scripts') @stack('scripts')
@captchaScripts
@livewireScripts @livewireScripts
</body> </body>
</html> </html>

View File

@@ -4,16 +4,18 @@
$colorClasses = [ $colorClasses = [
'outline' => 'hover:bg-gray-500 focus-visible:outline-primary-color text-gray-800 border border-gray-400 bg-white hover:text-white', 'outline' => 'hover:bg-gray-500 focus-visible:outline-primary-color text-gray-800 border border-gray-400 bg-white hover:text-white',
'primary' => 'hover:bg-primary-color-dark focus-visible:outline-primary-color bg-primary-color text-white', 'primary' => 'hover:bg-primary-color-dark focus-visible:outline-primary-color bg-primary-color text-white',
'primary-sm' => '!font-normal !text-xs !px-4 !py-1 hover:bg-primary-color-dark focus-visible:outline-primary-color bg-primary-color text-white',
'primary-outline' => 'hover:bg-primary-color-dark focus-visible:outline-primary-color text-primary-color border border-primary-color bg-white hover:text-white', 'primary-outline' => 'hover:bg-primary-color-dark focus-visible:outline-primary-color text-primary-color border border-primary-color bg-white hover:text-white',
'primary-outline-sm' => '!font-normal !text-xs !px-4 !py-1 hover:bg-primary-color-dark focus-visible:outline-primary-color text-primary-color border border-primary-color bg-white hover:text-white', 'primary-outline-sm' => '!font-normal !text-xs !px-4 !py-1 hover:bg-primary-color-dark focus-visible:outline-primary-color text-primary-color border border-primary-color bg-white hover:text-white',
'danger' => 'hover:bg-danger-color-dark focus-visible:outline-danger-color bg-danger-color text-white', 'danger' => 'hover:bg-danger-color-dark focus-visible:outline-danger-color bg-danger-color text-white',
'danger-outline' => 'hover:bg-danger-color-dark focus-visible:outline-danger-color text-danger-color border border-danger-color bg-white hover:text-white',
'success' => 'hover:bg-success-color-dark focus-visible:outline-success-color bg-success-color text-white' 'success' => 'hover:bg-success-color-dark focus-visible:outline-success-color bg-success-color text-white'
][$color]; ][$color];
$commonClasses = @twMerge(['whitespace-nowrap', 'text-center','justify-center','rounded-md','px-8','py-1.5','text-sm','font-semibold','leading-6','shadow-sm','focus-visible:outline','focus-visible:outline-2','focus-visible:outline-offset-2','transition'], ($class ?? '')); $commonClasses = @twMerge(['whitespace-nowrap', 'text-center','justify-center','rounded-md','px-8','py-1.5','text-sm','font-semibold','leading-6','shadow-sm','focus-visible:outline','focus-visible:outline-2','focus-visible:outline-offset-2','transition'], ($class ?? ''));
@endphp @endphp
@if($type == 'submit' || $type == 'button') @if($type === 'submit' || $type === 'button')
<button type="{{ $type }}" class="{{ $colorClasses . ' ' . $commonClasses }}" {{ $attributes }}>{{ $slot }}</button> <button type="{{ $type }}" class="{{ $colorClasses . ' ' . $commonClasses }}" {{ $attributes }}>{{ $slot }}</button>
@elseif($type == 'link') @elseif($type === 'link')
<a href="{{ $href ?? '#' }}" target="{{ $target ?? '_self' }}" class="{{ $colorClasses . ' ' . $commonClasses }}" {{ $attributes }}">{{ $slot }}</a> <a href="{{ $href ?? '#' }}" target="{{ $target ?? '_self' }}" class="{{ $colorClasses . ' ' . $commonClasses }}" {{ $attributes }}">{{ $slot }}</a>
@endif @endif

View File

@@ -0,0 +1,13 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We just wanted to let you know that someone just logged in using a backup code.</p>
<p>If this was you, then it is all good!</p>
<p>If it's not, we recommend you let us know by replying to this email and resetting your backup codes by:</p>
<ul>
<li>Logging into your account on STEMMechanics</li>
<li>Visit your account page</li>
<li>Under <strong>Two Factor Authentication</strong> - Click <i>Reset Backup Codes</i></li>
</ul>
<p>Warm regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -0,0 +1,8 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We just wanted to let you know that using an <strong>Authenticator App</strong> to log in to your account on STEMMechanics has been <strong>Disabled</strong>.</p>
<p>If this was you, then it is all good! - Any previous <i>Backup TFA Codes</i> can no longer be used.</p>
<p>If it's not, we recommend you let us know by replying to this email.</p>
<p>Regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -0,0 +1,8 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We just wanted to let you know that using an <strong>Authenticator App</strong> to log in to your account on STEMMechanics has been <strong>Enabled</strong>.</p>
<p>If this was you, then it is all good!</p>
<p>If it's not, we recommend you let us know by replying to this email.</p>
<p>Regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -67,7 +67,7 @@ h4 {
text-align: left; text-align: left;
} }
p { p, li {
font-size: 14px; font-size: 14px;
line-height: 1.2em; line-height: 1.2em;
margin-top: 0; margin-top: 0;

View File

@@ -27,6 +27,11 @@ Route::middleware('auth')->group(function () {
Route::get('/account', [AccountController::class, 'show'])->name('account.show'); Route::get('/account', [AccountController::class, 'show'])->name('account.show');
Route::post('/account', [AccountController::class, 'update'])->name('account.update'); Route::post('/account', [AccountController::class, 'update'])->name('account.update');
Route::delete('/account', [AccountController::class, 'destroy'])->name('account.destroy'); Route::delete('/account', [AccountController::class, 'destroy'])->name('account.destroy');
Route::get('/account/2fa', [AccountController::class, 'show_tfa'])->name('account.show.tfa');
Route::get('/account/2fa/image', [AccountController::class, 'show_tfa_image'])->name('account.show.tfa.image');
Route::post('/account/2fa', [AccountController::class, 'post_tfa'])->name('account.post.tfa');
Route::post('/account/2fa/reset-backup-codes', [AccountController::class, 'post_tfa_reset_backup_codes'])->name('account.post.tfa.reset-backup-codes');
Route::delete('/account/2fa', [AccountController::class, 'destroy_tfa'])->name('account.destroy.tfa');
}); });
Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); Route::get('/login', [AuthController::class, 'showLogin'])->name('login');