feat: add app and database modules
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
// Batch common auth settings
|
||||
$settings = [
|
||||
'2fa_enabled' => get_setting('two_factor_auth', false),
|
||||
'allow_remember' => get_setting('session_allow_remember_me', true),
|
||||
'remember_duration' => get_setting('session_remember_me_duration', 30),
|
||||
'single_session' => get_setting('session_single_session', false),
|
||||
];
|
||||
|
||||
$credentials = $request->only('email', 'password');
|
||||
$remember = $settings['allow_remember'] && $request->boolean('remember');
|
||||
|
||||
// Check if 2FA is enabled globally
|
||||
if ($settings['2fa_enabled']) {
|
||||
if (Auth::validate($credentials)) {
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
// Check Trust Device bypass (Secure check)
|
||||
$cookieValue = $request->cookie('2fa_trust_device');
|
||||
$trustedBypass = false;
|
||||
|
||||
if ($cookieValue && str_contains($cookieValue, '|')) {
|
||||
$parts = explode('|', $cookieValue, 2);
|
||||
|
||||
if (count($parts) === 2 && ! empty($parts[0]) && ! empty($parts[1])) {
|
||||
[$uuid, $secret] = $parts;
|
||||
|
||||
$trust = UserTrustedDevice::where('user_id', $user->id)
|
||||
->where('device_id', $uuid)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
|
||||
if ($trust && hash_equals($trust->token, hash('sha256', $secret))) {
|
||||
$trustedBypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($trustedBypass) {
|
||||
Auth::attempt($credentials, $remember);
|
||||
$request->session()->regenerate();
|
||||
$user->update(['last_session_id' => session()->getId()]);
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
// Generate & Send OTP
|
||||
TwoFactorController::generateAndSendOtp($user);
|
||||
session(['auth.2fa_remember' => $remember]);
|
||||
|
||||
return redirect()->route('2fa.index');
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate user credentials normally
|
||||
$request->authenticate();
|
||||
$request->session()->regenerate();
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$user->update(['last_session_id' => session()->getId()]);
|
||||
|
||||
// Custom duration Remember Me
|
||||
if ($remember) {
|
||||
$minutes = 60 * 24 * $settings['remember_duration'];
|
||||
cookie()->queue(
|
||||
cookie(
|
||||
name: Auth::getRecallerName(),
|
||||
value: cookie()->get(Auth::getRecallerName()),
|
||||
minutes: $minutes,
|
||||
httpOnly: true,
|
||||
secure: app()->environment('production'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// SINGLE SESSION ENFORCEMENT
|
||||
if ($settings['single_session']) {
|
||||
Auth::logoutOtherDevices($request->password);
|
||||
}
|
||||
|
||||
return redirect()->intended('/dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
// Invalidate session for security
|
||||
$request->session()->invalidate();
|
||||
|
||||
// Regenerate CSRF token
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Redirect to homepage
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
session()->regenerate();
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', PasswordPolicyService::getRules(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$newPassword = $validated['password'];
|
||||
|
||||
// Check History
|
||||
PasswordPolicyService::checkHistory($user, $newPassword);
|
||||
|
||||
// Must be called before password is updated so current hash still matches
|
||||
Auth::logoutOtherDevices($request->current_password);
|
||||
|
||||
$passwordHash = Hash::make($newPassword);
|
||||
$user->update([
|
||||
'password' => $passwordHash,
|
||||
]);
|
||||
|
||||
// Record Change & History
|
||||
PasswordPolicyService::recordPasswordChange($user, $passwordHash);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Password updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
/**
|
||||
* ==========================================
|
||||
* ❌ BLOCK RESET PASSWORD SOCIAL USER
|
||||
* ==========================================
|
||||
*/
|
||||
if ($user && $user->isSocialUser()) {
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors([
|
||||
'email' => __('Please sign in using your Social Provider for this account.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ==========================================
|
||||
* ✅ USER MANUAL → NORMAL
|
||||
* ==========================================
|
||||
*/
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
|
||||
// namespace App\Http\Controllers\Auth;
|
||||
|
||||
// use App\Http\Controllers\Controller;
|
||||
// use Illuminate\Http\RedirectResponse;
|
||||
// use Illuminate\Http\Request;
|
||||
// use Illuminate\Support\Facades\Password;
|
||||
// use Illuminate\View\View;
|
||||
|
||||
// class PasswordResetLinkController extends Controller
|
||||
// {
|
||||
// /**
|
||||
// * Display the password reset link request view.
|
||||
// */
|
||||
// public function create(): View
|
||||
// {
|
||||
// return view('auth.forgot-password');
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Handle an incoming password reset link request.
|
||||
// *
|
||||
// * @throws \Illuminate\Validation\ValidationException
|
||||
// */
|
||||
// public function store(Request $request): RedirectResponse
|
||||
// {
|
||||
// $request->validate([
|
||||
// 'email' => ['required', 'email'],
|
||||
// ]);
|
||||
|
||||
// // We will send the password reset link to this user. Once we have attempted
|
||||
// // to send the link, we will examine the response then see the message we
|
||||
// // need to show to the user. Finally, we'll send out a proper response.
|
||||
// $status = Password::sendResetLink(
|
||||
// $request->only('email')
|
||||
// );
|
||||
|
||||
// return $status == Password::RESET_LINK_SENT
|
||||
// ? back()->with('status', __($status))
|
||||
// : back()->withInput($request->only('email'))
|
||||
// ->withErrors(['email' => __($status)]);
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\UserConsent;
|
||||
use App\Notifications\Auth\LegalConsentConfirmation;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', PasswordPolicyService::getRules()],
|
||||
'agree_tos_pdp' => ['required', 'accepted'],
|
||||
'marketing_consent' => ['nullable'], // Fix: removed 'boolean' to handle "on" value from checkbox
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password, // Rely on 'hashed' cast in User model
|
||||
]);
|
||||
|
||||
// Record to history and set initial password_changed_at
|
||||
PasswordPolicyService::recordPasswordChange($user, $user->password);
|
||||
|
||||
// DEFAULT ROLE = User
|
||||
$user->assignRole('User');
|
||||
|
||||
// RECORD CONSENT AUDIT LOGS (UU PDP COMPLIANCE)
|
||||
$this->recordUserConsents($user, $request);
|
||||
|
||||
// TRIGGER CONFIRMATION EMAIL (Wrapped in try-catch to prevent registration failure on mail errors)
|
||||
try {
|
||||
$user->notify(new LegalConsentConfirmation([
|
||||
'tos' => $this->systemConfig->get('tos_document_version', 1),
|
||||
'privacy' => $this->systemConfig->get('pdp_document_version', 1),
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to send registration consent email: '.$e->getMessage());
|
||||
}
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the audit log for user consents.
|
||||
*/
|
||||
protected function recordUserConsents(User $user, Request $request): void
|
||||
{
|
||||
$ip = $request->ip();
|
||||
$ua = $request->userAgent();
|
||||
|
||||
// 1. TOS & PDP (Mandatory)
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'tos',
|
||||
'version_id' => (int) $this->systemConfig->get('tos_document_version', 1),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'privacy',
|
||||
'version_id' => (int) $this->systemConfig->get('pdp_document_version', 1),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
|
||||
// 2. Marketing (Optional)
|
||||
if ($request->boolean('marketing_consent')) {
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'marketing',
|
||||
'version_id' => 1,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class SocialAuthController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Redirect to Provider OAuth
|
||||
*/
|
||||
public function redirect(string $provider)
|
||||
{
|
||||
$this->ensureFeatureEnabled($provider);
|
||||
|
||||
// Save provider to session for the unified callback
|
||||
session(['social_auth_provider' => $provider]);
|
||||
|
||||
return Socialite::driver($provider)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Provider Callback
|
||||
*/
|
||||
public function callback()
|
||||
{
|
||||
$provider = session('social_auth_provider');
|
||||
|
||||
if (! $provider) {
|
||||
return redirect('/login')->with('error', __('Authentication provider not found in session.'));
|
||||
}
|
||||
|
||||
$this->ensureFeatureEnabled($provider);
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($provider)->user();
|
||||
} catch (Exception $e) {
|
||||
return redirect('/login')->with('error', __(':provider authentication failed.', ['provider' => ucfirst($provider)]));
|
||||
}
|
||||
|
||||
$idColumn = $provider.'_id'; // google_id, facebook_id, github_id
|
||||
|
||||
// Reject if the OAuth provider signals the email is not verified
|
||||
$emailVerified = $socialUser->user['email_verified'] ?? null;
|
||||
if ($emailVerified === false) {
|
||||
return redirect('/login')->with('error', __('Your :provider email address is not verified. Please verify it and try again.', ['provider' => ucfirst($provider)]));
|
||||
}
|
||||
|
||||
// Primary lookup: by provider-specific ID (not spoofable)
|
||||
$user = User::where($idColumn, $socialUser->id)->first();
|
||||
|
||||
// Secondary lookup: by email only if no provider-ID match exists yet
|
||||
// (covers first-time OAuth login for users who registered via email)
|
||||
if (! $user && $socialUser->email) {
|
||||
$byEmail = User::where('email', $socialUser->email)->first();
|
||||
if ($byEmail) {
|
||||
// Only link if the existing account does NOT already belong to a different OAuth identity
|
||||
if (empty($byEmail->{$idColumn})) {
|
||||
$user = $byEmail;
|
||||
} else {
|
||||
// Email already linked to a different identity on this provider — refuse silently
|
||||
// to avoid leaking that the account exists or letting an attacker take it over.
|
||||
return redirect('/login')->with('error', __('This email is already linked to a different account. Please sign in with your original method.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
// Register new user
|
||||
$user = User::create([
|
||||
'name' => $socialUser->name ?? $socialUser->nickname ?? $socialUser->email,
|
||||
'email' => $socialUser->email,
|
||||
$idColumn => $socialUser->id,
|
||||
'avatar' => $socialUser->avatar,
|
||||
'password' => bcrypt(Str::random(32)),
|
||||
]);
|
||||
|
||||
// Assign default role
|
||||
$user->assignRole('User');
|
||||
} else {
|
||||
// Sync Social ID and Avatar
|
||||
$user->update([
|
||||
$idColumn => $socialUser->id,
|
||||
'avatar' => $socialUser->avatar,
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::login($user, true);
|
||||
|
||||
session()->forget('social_auth_provider');
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->intended('/dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the requested provider is enabled in settings
|
||||
*/
|
||||
protected function ensureFeatureEnabled(string $provider): void
|
||||
{
|
||||
$settingKey = 'feature_'.$provider.'_oauth';
|
||||
|
||||
if ($provider === 'facebook' || $provider === 'github') {
|
||||
// GitHub and Facebook keys follow the 'feature_{provider}_oauth' pattern
|
||||
}
|
||||
|
||||
abort_unless($this->systemConfig->get($settingKey, false), 404, __('Provider not enabled.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\TwoFactorOtp;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TwoFactorController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (! Session::has('auth.2fa_user_id')) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Check if device is already trusted (Redirect to verify immediately with cookie token)
|
||||
$userId = Session::get('auth.2fa_user_id');
|
||||
$deviceId = request()->cookie('2fa_trust_device');
|
||||
|
||||
if ($deviceId && str_contains($deviceId, '|')) {
|
||||
$parts = explode('|', $deviceId, 2);
|
||||
if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) {
|
||||
return view('auth.two-factor');
|
||||
}
|
||||
[$uuid, $secret] = $parts;
|
||||
|
||||
$trust = UserTrustedDevice::where('user_id', $userId)
|
||||
->where('device_id', $uuid)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
|
||||
if ($trust && hash_equals($trust->token, hash('sha256', $secret))) {
|
||||
// Auto login and skip 2FA view
|
||||
$remember = Session::get('auth.2fa_remember', false);
|
||||
Auth::loginUsingId($userId, $remember);
|
||||
Session::forget(['auth.2fa_user_id', 'auth.2fa_code', 'auth.2fa_expires_at', 'auth.2fa_remember']);
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
||||
return view('auth.two-factor');
|
||||
}
|
||||
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'code' => 'required|string|size:6',
|
||||
'trust_device' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$userId = Session::get('auth.2fa_user_id');
|
||||
$storedCode = Session::get('auth.2fa_code');
|
||||
$expiresAt = Session::get('auth.2fa_expires_at');
|
||||
|
||||
if (! $userId || ! $storedCode || ! hash_equals((string) $storedCode, (string) $request->code)) {
|
||||
return back()->with('error', __('Invalid verification code.'));
|
||||
}
|
||||
|
||||
if (! $expiresAt || now()->timestamp > $expiresAt) {
|
||||
Session::forget(['auth.2fa_user_id', 'auth.2fa_code', 'auth.2fa_expires_at', 'auth.2fa_remember']);
|
||||
|
||||
return redirect()->route('login')->with('error', __('Verification code has expired. Please log in again.'));
|
||||
}
|
||||
|
||||
// Handle Trust Device
|
||||
if ($request->boolean('trust_device')) {
|
||||
$this->issueTrustToken($userId);
|
||||
}
|
||||
|
||||
// Login user
|
||||
$remember = Session::get('auth.2fa_remember', false);
|
||||
Auth::loginUsingId($userId, $remember);
|
||||
|
||||
// Clear 2FA session then regenerate to prevent fixation
|
||||
Session::forget(['auth.2fa_user_id', 'auth.2fa_code', 'auth.2fa_expires_at', 'auth.2fa_remember']);
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
protected function issueTrustToken($userId)
|
||||
{
|
||||
$deviceId = Str::uuid();
|
||||
$token = Str::random(64);
|
||||
$days = get_setting('two_factor_trust_days', 30);
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $userId,
|
||||
'device_id' => $deviceId,
|
||||
'token' => hash('sha256', $token),
|
||||
'expires_at' => now()->addDays($days),
|
||||
]);
|
||||
|
||||
// Queue cookie with both UUID and Secret
|
||||
cookie()->queue(
|
||||
'2fa_trust_device',
|
||||
$deviceId.'|'.$token,
|
||||
$days * 24 * 60,
|
||||
null, null, true, true // secure, httpOnly
|
||||
);
|
||||
}
|
||||
|
||||
public static function generateAndSendOtp($user)
|
||||
{
|
||||
$otp = str_pad((string) (hexdec(bin2hex(random_bytes(3))) % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
$expiresAt = now()->addMinutes(10)->timestamp;
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', $otp);
|
||||
Session::put('auth.2fa_expires_at', $expiresAt);
|
||||
|
||||
try {
|
||||
$request = request();
|
||||
Mail::to($user->email)->send(new TwoFactorOtp(
|
||||
otp: $otp,
|
||||
userName: $user->name,
|
||||
ipAddress: $request->ip(),
|
||||
userAgent: $request->userAgent(),
|
||||
));
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to send 2FA Email: '.$e->getMessage());
|
||||
}
|
||||
|
||||
session()->flash('info', __('Verification code has been sent to your email.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user