feat: add app and database modules

This commit is contained in:
2026-05-21 16:05:11 +07:00
parent 37b7e783f5
commit fad70d096b
212 changed files with 23901 additions and 0 deletions
@@ -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');
}
}