captcha cleanup and added 2fa logins
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
$user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
|
||||||
if($user) {
|
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 (!$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')
|
||||||
|
|||||||
31
app/Mail/UserLoginBackupCode.php
Normal file
31
app/Mail/UserLoginBackupCode.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Mail/UserLoginTFADisabled.php
Normal file
31
app/Mail/UserLoginTFADisabled.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Mail/UserLoginTFAEnabled.php
Normal file
31
app/Mail/UserLoginTFAEnabled.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/Models/UserBackupCode.php
Normal file
44
app/Models/UserBackupCode.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Providers/CaptchaServiceProvider.php
Normal file
62
app/Providers/CaptchaServiceProvider.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Providers/QRCodeProvider.php
Normal file
23
app/Providers/QRCodeProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\CaptchaServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
8994
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
68
resources/views/auth/login-2fa.blade.php
Normal file
68
resources/views/auth/login-2fa.blade.php
Normal 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>
|
||||||
14
resources/views/auth/login-alt.blade.php
Normal file
14
resources/views/auth/login-alt.blade.php
Normal 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>
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
</script>
|
</script>
|
||||||
@endif
|
@endif
|
||||||
@stack('scripts')
|
@stack('scripts')
|
||||||
|
@captchaScripts
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
resources/views/emails/login-backup-code.blade.php
Normal file
13
resources/views/emails/login-backup-code.blade.php
Normal 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
|
||||||
8
resources/views/emails/login-tfa-disabled.blade.php
Normal file
8
resources/views/emails/login-tfa-disabled.blade.php
Normal 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
|
||||||
8
resources/views/emails/login-tfa-enabled.blade.php
Normal file
8
resources/views/emails/login-tfa-enabled.blade.php
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user