diff --git a/.env.example b/.env.example index d76bd72..6392938 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,59 @@ MEMCACHED_HOST=127.0.0.1 REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 + +# OAuth Configuration +# Global OAuth system control +OAUTH_ENABLED=true + +# Individual OAuth provider controls +OAUTH_GOOGLE_ENABLED=true +OAUTH_GITHUB_ENABLED=true +OAUTH_DISCORD_ENABLED=true + +# OAuth Registration Settings +OAUTH_REGISTRATION_ENABLED=true +# OAuth registration mode: open, whitelist, invite_only, admin_approval +# - open: Anyone can register (default) +# - whitelist: Only specific email domains allowed (see OAUTH_ALLOWED_DOMAINS) +# - invite_only: Only users with valid invite codes can register +# - admin_approval: Accounts require admin approval after creation +OAUTH_REGISTRATION_MODE=open +OAUTH_ALLOWED_DOMAINS= +OAUTH_BLOCKED_DOMAINS=tempmail.com,10minutemail.com +OAUTH_BLOCKED_EMAILS= + +# OAuth Invite System +OAUTH_INVITES_ENABLED=false +OAUTH_INVITE_EXPIRE_DAYS=7 +OAUTH_INVITE_SINGLE_USE=true + +# OAuth Admin Approval +OAUTH_ADMIN_APPROVAL_ENABLED=false +OAUTH_NOTIFY_ADMINS=true + +# OAuth Security Settings +OAUTH_REQUIRE_EMAIL_VERIFICATION=false +OAUTH_MAX_ACCOUNTS_PER_EMAIL=1 +OAUTH_RATE_LIMIT_ENABLED=true +OAUTH_RATE_LIMIT_ATTEMPTS=5 +OAUTH_RATE_LIMIT_DECAY=60 + +# OAuth Provider Credentials +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= + +# GitHub OAuth +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI= + +# Discord OAuth +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI= + +# SSL Verification (for OAuth requests) +CURL_VERIFY_SSL=true diff --git a/app/Console/Commands/ManageOAuthInvites.php b/app/Console/Commands/ManageOAuthInvites.php new file mode 100644 index 0000000..f732a68 --- /dev/null +++ b/app/Console/Commands/ManageOAuthInvites.php @@ -0,0 +1,209 @@ +argument('action'); + + switch ($action) { + case 'create': + return $this->createInvite(); + case 'list': + return $this->listInvites(); + case 'revoke': + return $this->revokeInvite(); + case 'cleanup': + return $this->cleanupInvites(); + default: + $this->error('Invalid action. Available actions: create, list, revoke, cleanup'); + return 1; + } + } + + /** + * Create a new OAuth invite + */ + private function createInvite() + { + $email = $this->option('email'); + if (!$email) { + $email = $this->ask('Email address for the invite (optional)'); + } + + if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('Invalid email address format.'); + return 1; + } + + $expiresHours = $this->option('expires') ?? 168; // 7 days default + $maxUses = $this->option('max-uses') ?? 1; + $singleUse = $this->option('single-use') || $maxUses == 1; + + try { + $invite = OAuthInvite::createInvite( + email: $email, + expiresAt: now()->addHours($expiresHours), + singleUse: $singleUse, + maxUses: $singleUse ? 1 : $maxUses + ); + + $this->info('OAuth invite created successfully!'); + $this->table( + ['Field', 'Value'], + [ + ['Code', $invite->code], + ['Email', $invite->email ?? 'Any email'], + ['Expires At', $invite->expires_at->format('Y-m-d H:i:s')], + ['Max Uses', $invite->max_uses], + ['Single Use', $invite->single_use ? 'Yes' : 'No'], + ] + ); + + $this->line(''); + $this->info('Share this invite URL:'); + $loginUrl = url('/login?invite_code=' . $invite->code); + $this->line($loginUrl); + + return 0; + } catch (\Exception $e) { + $this->error('Failed to create invite: ' . $e->getMessage()); + return 1; + } + } + + /** + * List OAuth invites + */ + private function listInvites() + { + $includeExpired = $this->option('expired'); + + $query = OAuthInvite::query(); + + if (!$includeExpired) { + $query->where('expires_at', '>', now()); + } + + $invites = $query->orderBy('created_at', 'desc')->get(); + + if ($invites->isEmpty()) { + $this->info('No invites found.'); + return 0; + } + + $headers = ['Code', 'Email', 'Status', 'Uses', 'Expires At', 'Created At']; + $rows = []; + + foreach ($invites as $invite) { + $status = 'Active'; + if ($invite->expires_at < now()) { + $status = 'Expired'; + } elseif ($invite->single_use && $invite->used_count > 0) { + $status = 'Used'; + } elseif ($invite->used_count >= $invite->max_uses) { + $status = 'Exhausted'; + } + + $rows[] = [ + substr($invite->code, 0, 12) . '...', + $invite->email ?? 'Any', + $status, + $invite->used_count . '/' . $invite->max_uses, + $invite->expires_at->format('Y-m-d H:i'), + $invite->created_at->format('Y-m-d H:i'), + ]; + } + + $this->table($headers, $rows); + $this->info('Total invites: ' . $invites->count()); + + return 0; + } + + /** + * Revoke an OAuth invite + */ + private function revokeInvite() + { + $code = $this->option('code'); + if (!$code) { + $code = $this->ask('Enter the invite code to revoke'); + } + + if (!$code) { + $this->error('Invite code is required.'); + return 1; + } + + $invite = OAuthInvite::where('code', $code)->first(); + if (!$invite) { + $this->error('Invite code not found.'); + return 1; + } + + if ($this->confirm('Are you sure you want to revoke this invite?')) { + $invite->delete(); + $this->info('Invite revoked successfully.'); + } else { + $this->info('Revocation cancelled.'); + } + + return 0; + } + + /** + * Cleanup expired invites + */ + private function cleanupInvites() + { + $expiredCount = OAuthInvite::where('expires_at', '<', now())->count(); + + if ($expiredCount === 0) { + $this->info('No expired invites to clean up.'); + return 0; + } + + $this->info("Found {$expiredCount} expired invite(s)."); + + if ($this->confirm('Do you want to delete all expired invites?')) { + $deleted = OAuthInvite::where('expires_at', '<', now())->delete(); + $this->info("Deleted {$deleted} expired invite(s)."); + } else { + $this->info('Cleanup cancelled.'); + } + + return 0; + } +} diff --git a/app/Helpers/HtmlSanitizer.php b/app/Helpers/HtmlSanitizer.php new file mode 100644 index 0000000..b488698 --- /dev/null +++ b/app/Helpers/HtmlSanitizer.php @@ -0,0 +1,117 @@ +<', $allowedTags) . '>'); + + // Remove dangerous attributes like onclick, onload, etc. + $content = preg_replace('/\s*on\w+\s*=\s*["\'][^"\'>]*["\']/', '', $content); + $content = preg_replace('/\s*javascript\s*:/', '', $content); + $content = preg_replace('/\s*vbscript\s*:/', '', $content); + $content = preg_replace('/\s*data\s*:/', '', $content); + + // Remove script and style tags completely + $content = preg_replace('/]*>.*?<\/script>/is', '', $content); + $content = preg_replace('/]*>.*?<\/style>/is', '', $content); + + return $content; + } + + /** + * Sanitize content for display in forms (more restrictive) + * + * @param string|null $content + * @return string + */ + public static function sanitizeForForm(?string $content): string + { + if (empty($content)) { + return ''; + } + + // For form display, only allow basic formatting + $allowedTags = ['b', 'i', 'u', 'strong', 'em', 'br']; + $content = strip_tags($content, '<' . implode('><', $allowedTags) . '>'); + + // Remove all attributes for form display + $content = preg_replace('/\s+[a-zA-Z-]+\s*=\s*["\'][^"\'>]*["\']/', '', $content); + + return $content; + } + + /** + * Convert Minecraft color codes to safe HTML + * + * @param string|null $content + * @return string + */ + public static function minecraftToHtml(?string $content): string + { + if (empty($content)) { + return ''; + } + + // Minecraft color code mapping + $colorMap = [ + '§0' => '', + '§1' => '', + '§2' => '', + '§3' => '', + '§4' => '', + '§5' => '', + '§6' => '', + '§7' => '', + '§8' => '', + '§9' => '', + '§a' => '', + '§b' => '', + '§c' => '', + '§d' => '', + '§e' => '', + '§f' => '', + '§l' => '', + '§o' => '', + '§n' => '', + '§r' => '' + ]; + + // Replace Minecraft codes with HTML + foreach ($colorMap as $code => $html) { + $content = str_replace($code, $html, $content); + } + + // Also handle & codes + foreach ($colorMap as $code => $html) { + $ampCode = str_replace('§', '&', $code); + $content = str_replace($ampCode, $html, $content); + } + + return $content; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Admin/OAuthInviteController.php b/app/Http/Controllers/Admin/OAuthInviteController.php new file mode 100644 index 0000000..774d5a1 --- /dev/null +++ b/app/Http/Controllers/Admin/OAuthInviteController.php @@ -0,0 +1,174 @@ +middleware('auth'); + } + + /** + * Display a listing of OAuth invites + */ + public function index(Request $request) + { + $this->authorize('manage_groups_and_accounts'); + $query = OAuthInvite::query(); + + // Filter by status + if ($request->has('status')) { + switch ($request->status) { + case 'active': + $query->where('expires_at', '>', now()) + ->where(function($q) { + $q->where('used_count', '<', 'max_uses') + ->orWhere('single_use', false); + }); + break; + case 'expired': + $query->where('expires_at', '<=', now()); + break; + case 'used': + $query->where('used_count', '>=', 'max_uses'); + break; + } + } + + // Search by email or code + if ($request->has('search') && $request->search) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('email', 'like', "%{$search}%") + ->orWhere('code', 'like', "%{$search}%"); + }); + } + + $invites = $query->orderBy('created_at', 'desc') + ->paginate(20) + ->withQueryString(); + + return view('admin.oauth-invites.index', compact('invites')); + } + + /** + * Show the form for creating a new OAuth invite + */ + public function create() + { + $this->authorize('manage_groups_and_accounts'); + + return view('admin.oauth-invites.create'); + } + + /** + * Store a newly created OAuth invite + */ + public function store(Request $request) + { + $this->authorize('manage_groups_and_accounts'); + + $validator = Validator::make($request->all(), [ + 'email' => 'nullable|email|max:255', + 'expires_hours' => 'required|integer|min:1|max:8760', // Max 1 year + 'max_uses' => 'required|integer|min:1|max:1000', + 'single_use' => 'boolean', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + try { + $expiresHours = (int) $request->expires_hours; + $invite = OAuthInvite::createInvite( + $request->email, + now()->addHours($expiresHours), + $request->boolean('single_use'), + $request->single_use ? 1 : (int) $request->max_uses, + auth()->user()->id + ); + + return redirect()->route('admin.oauth-invites.index') + ->with('success', 'OAuth invite created successfully!'); + + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to create invite: ' . $e->getMessage()]) + ->withInput(); + } + } + + /** + * Display the specified OAuth invite + */ + public function show(OAuthInvite $oauthInvite) + { + $this->authorize('manage_groups_and_accounts'); + + $oauthInvite->load(['creator', 'usedBy']); + return view('admin.oauth-invites.show', compact('oauthInvite')); + } + + /** + * Remove the specified OAuth invite + */ + public function destroy(OAuthInvite $oauthInvite) + { + $this->authorize('manage_groups_and_accounts'); + + try { + $oauthInvite->delete(); + return redirect()->route('admin.oauth-invites.index') + ->with('success', 'OAuth invite deleted successfully!'); + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to delete invite: ' . $e->getMessage()]); + } + } + + /** + * Bulk cleanup expired invites + */ + public function cleanup() + { + $this->authorize('manage_groups_and_accounts'); + + try { + $deleted = OAuthInvite::where('expires_at', '<', now())->delete(); + return redirect()->route('admin.oauth-invites.index') + ->with('success', "Cleaned up {$deleted} expired invite(s)."); + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to cleanup invites: ' . $e->getMessage()]); + } + } + + /** + * Generate invite URL + */ + public function generateUrl(OAuthInvite $oauthInvite) + { + $this->authorize('manage_groups_and_accounts'); + + $url = url('/login?invite_code=' . $oauthInvite->code); + + return response()->json([ + 'success' => true, + 'url' => $url + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Admin/UserApprovalController.php b/app/Http/Controllers/Admin/UserApprovalController.php new file mode 100644 index 0000000..24fe67b --- /dev/null +++ b/app/Http/Controllers/Admin/UserApprovalController.php @@ -0,0 +1,228 @@ +middleware('auth'); + } + + /** + * Display a listing of users pending approval + */ + public function index(Request $request) + { + $this->authorize('manage_groups_and_accounts'); + $query = User::query(); + + // Filter by approval status + $status = $request->get('status', 'pending'); + switch ($status) { + case 'pending': + $query->pendingApproval(); + break; + case 'approved': + $query->approved(); + break; + case 'rejected': + $query->rejected(); + break; + case 'all': + // No filter + break; + default: + $query->pendingApproval(); + } + + // Search by username or email + if ($request->has('search') && $request->search) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('username', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + // Filter by OAuth provider + if ($request->has('provider') && $request->provider) { + $query->where('oauth_provider', $request->provider); + } + + $users = $query->with(['approvedBy']) + ->orderBy('created_at', 'desc') + ->paginate(20) + ->withQueryString(); + + $pendingCount = User::pendingApproval()->count(); + $approvedCount = User::approved()->count(); + $rejectedCount = User::rejected()->count(); + + return view('admin.user-approvals.index', compact( + 'users', + 'status', + 'pendingCount', + 'approvedCount', + 'rejectedCount' + )); + } + + /** + * Show the specified user for approval + */ + public function show(User $user) + { + $this->authorize('manage_groups_and_accounts'); + $user->load(['approvedBy']); + return view('admin.user-approvals.show', compact('user')); + } + + /** + * Approve a user + */ + public function approve(Request $request, User $user) + { + $this->authorize('manage_groups_and_accounts'); + $validator = Validator::make($request->all(), [ + 'notes' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + try { + $user->approve(auth()->user(), $request->notes); + + // TODO: Send approval notification to user + + return redirect()->route('admin.user-approvals.index') + ->with('success', "User '{$user->username}' has been approved successfully!"); + + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to approve user: ' . $e->getMessage()]); + } + } + + /** + * Reject a user + */ + public function reject(Request $request, User $user) + { + $this->authorize('manage_groups_and_accounts'); + $validator = Validator::make($request->all(), [ + 'notes' => 'required|string|max:1000', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + try { + $user->reject(auth()->user(), $request->notes); + + // TODO: Send rejection notification to user + + return redirect()->route('admin.user-approvals.index') + ->with('success', "User '{$user->username}' has been rejected."); + + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to reject user: ' . $e->getMessage()]); + } + } + + /** + * Bulk approve users + */ + public function bulkApprove(Request $request) + { + $this->authorize('manage_groups_and_accounts'); + $validator = Validator::make($request->all(), [ + 'user_ids' => 'required|array', + 'user_ids.*' => 'exists:users,id', + 'notes' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + try { + $users = User::whereIn('id', $request->user_ids) + ->pendingApproval() + ->get(); + + $approved = 0; + foreach ($users as $user) { + if ($user->approve(auth()->user(), $request->notes)) { + $approved++; + } + } + + return redirect()->route('admin.user-approvals.index') + ->with('success', "Successfully approved {$approved} user(s)."); + + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to bulk approve users: ' . $e->getMessage()]); + } + } + + /** + * Bulk reject users + */ + public function bulkReject(Request $request) + { + $this->authorize('manage_groups_and_accounts'); + $validator = Validator::make($request->all(), [ + 'user_ids' => 'required|array', + 'user_ids.*' => 'exists:users,id', + 'notes' => 'required|string|max:1000', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + try { + $users = User::whereIn('id', $request->user_ids) + ->pendingApproval() + ->get(); + + $rejected = 0; + foreach ($users as $user) { + if ($user->reject(auth()->user(), $request->notes)) { + $rejected++; + } + } + + return redirect()->route('admin.user-approvals.index') + ->with('success', "Successfully rejected {$rejected} user(s)."); + + } catch (\Exception $e) { + return redirect()->back() + ->withErrors(['error' => 'Failed to bulk reject users: ' . $e->getMessage()]); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Webpanel/AuthenticationController.php b/app/Http/Controllers/Webpanel/AuthenticationController.php index ceef912..cc86093 100644 --- a/app/Http/Controllers/Webpanel/AuthenticationController.php +++ b/app/Http/Controllers/Webpanel/AuthenticationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webpanel; use App\Http\Controllers\Controller; +use App\Http\Requests\LoginRequest; use App\Models\User; use Carbon\Carbon; use Illuminate\Contracts\Auth\StatefulGuard; @@ -38,59 +39,21 @@ public function loginView(): View|Application|Factory|\Illuminate\Contracts\Foun /** * Authenticate the user. * - * @param Request $request + * @param LoginRequest $request * @return JsonResponse|RedirectResponse */ - public function login(Request $request): JsonResponse|RedirectResponse + public function login(LoginRequest $request): JsonResponse|RedirectResponse { - $this->validateLogin($request); - - if ($this->attemptLogin($request)) { - if ($request->hasSession()) { - $request->session()->put('auth.password_confirmed_at', time()); - } + $request->authenticate(); - return $this->sendLoginResponse($request); + if ($request->hasSession()) { + $request->session()->put('auth.password_confirmed_at', time()); } - return redirect()->back()->withErrors(['login' => 'Invalid login details']); + return $this->sendLoginResponse($request); } - /** - * Validate the user login request. - * - * @return void - * - */ - protected function validateLogin(Request $request): void - { - $request->validate([ - 'username' => 'required|string', - 'password' => 'required|string', - ]); - } - - /** - * Attempt to log the user into the application. - * - * @param Request $request - * @return bool - */ - protected function attemptLogin(Request $request): bool - { - return $this->guard()->attempt($this->credentials($request), $request->boolean('remember')); - } - /** - * Get the needed authorization credentials from the request. - * - * @param Request $request - * @return array - */ - protected function credentials(Request $request): array - { - return array_merge($request->only('username', 'password'), ['is_active' => 1]); - } /** * Send the response after the user was authenticated. diff --git a/app/Http/Controllers/Webpanel/OAuthController.php b/app/Http/Controllers/Webpanel/OAuthController.php new file mode 100644 index 0000000..c91d13a --- /dev/null +++ b/app/Http/Controllers/Webpanel/OAuthController.php @@ -0,0 +1,483 @@ +middleware('guest')->except(['logout', 'redirectToProviderForLinking', 'handleProviderLinkingCallback']); + } + + /** + * Redirect to OAuth provider + */ + public function redirectToProvider(Request $request, string $provider): RedirectResponse + { + if (!in_array($provider, $this->supportedProviders)) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'Unsupported OAuth provider.']); + } + + // Check if OAuth system is globally enabled + if (!config('oauth.enabled')) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'OAuth authentication is currently disabled.']); + } + + // Check if specific provider is enabled + if (!config("oauth.providers.{$provider}.enabled")) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => ucfirst($provider) . ' OAuth is currently disabled.']); + } + + try { + // Store invite code in session if provided + if ($request->has('invite_code')) { + $request->session()->put('oauth_invite_code', $request->invite_code); + } + + return Socialite::driver($provider)->redirect(); + } catch (Exception $e) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'OAuth configuration error. Please contact administrator.']); + } + } + + /** + * Handle OAuth callback + */ + public function handleProviderCallback(string $provider, Request $request): RedirectResponse + { + if (!in_array($provider, $this->supportedProviders)) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'Unsupported OAuth provider.']); + } + + // Check if we're in linking mode and redirect to linking handler + if ($request->session()->has('oauth_linking_mode') && $request->session()->has('oauth_linking_user_id')) { + $request->session()->flash('debug_message', 'OAuth callback received for ' . $provider . ' - Linking mode detected'); + return $this->handleProviderLinkingCallback($provider, $request); + } + + try { + // Check if OAuth system is globally enabled + if (!config('oauth.enabled')) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'OAuth authentication is currently disabled.']); + } + + // Check if specific provider is enabled + if (!config("oauth.providers.{$provider}.enabled")) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => ucfirst($provider) . ' OAuth is currently disabled.']); + } + + // Check if OAuth registration is enabled + if (!config('oauth.registration.enabled')) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'OAuth registration is currently disabled.']); + } + + // Rate limiting + $key = 'oauth-callback:' . $request->ip(); + if (config('oauth.security.rate_limit.enabled') && + RateLimiter::tooManyAttempts($key, config('oauth.security.rate_limit.max_attempts'))) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'Too many OAuth attempts. Please try again later.']); + } + + $oauthUser = Socialite::driver($provider)->user(); + + // Check if this OAuth account is already linked to any user + $existingOAuthProvider = UserOAuthProvider::findByProvider($provider, $oauthUser->getId()); + + if ($existingOAuthProvider) { + // OAuth account is already linked to a user + if (Auth::check() && Auth::id() !== $existingOAuthProvider->user_id) { + // Different user is logged in, show error + return redirect()->route('auth.login') + ->with('error', __('This :provider account is already linked to another user.', ['provider' => $this->getProviderDisplayName($provider)])); + } + + // Log in the existing user + Auth::login($existingOAuthProvider->user, true); + RateLimiter::clear($key); + $request->session()->regenerate(); + return redirect()->intended('/'); + } + + // Get invite code from session if available + $inviteCode = $request->session()->get('oauth_invite_code'); + if ($inviteCode) { + $request->merge(['invite_code' => $inviteCode]); + $request->session()->forget('oauth_invite_code'); + } + + // Check if invite code is required for new registrations + if (!$request->has('invite_code') && config('oauth.registration.mode') === 'invite_only') { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'An invitation code is required to register with OAuth. Please contact an administrator.']); + } + + // New user registration - check restrictions + $restrictionCheck = $this->checkRegistrationRestrictions($oauthUser, $request); + if ($restrictionCheck !== true) { + RateLimiter::hit($key, config('oauth.security.rate_limit.decay_minutes', 60) * 60); + return redirect()->route('auth.login') + ->withErrors(['oauth' => $restrictionCheck]); + } + + // Prepare user data + $userData = [ + 'id' => $oauthUser->getId(), + 'name' => $oauthUser->getName(), + 'nickname' => $oauthUser->getNickname(), + 'email' => $oauthUser->getEmail(), + 'avatar' => $oauthUser->getAvatar(), + ]; + + // Create new user with multi-provider support + $user = User::createFromOAuth($provider, $userData); + + // Handle invite usage if applicable + if ($request->has('invite_code')) { + $invite = OAuthInvite::findValidInvite($request->invite_code, $oauthUser->getEmail()); + if ($invite) { + $invite->use($user->id); + } + } + + // Handle admin approval mode + if (config('oauth.registration.mode') === 'admin_approval') { + $user->update([ + 'is_active' => false, + 'approval_status' => 'pending' + ]); + // TODO: Send notification to admins + return redirect()->route('auth.login') + ->with('message', 'Your account has been created and is pending admin approval. You will be notified once approved.'); + } + + // Log the user in + Auth::login($user, true); + RateLimiter::clear($key); + + // Regenerate session + $request->session()->regenerate(); + + return redirect()->intended('/'); + + } catch (Exception $e) { + \Log::error('OAuth authentication failed', [ + 'provider' => $provider, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + RateLimiter::hit($key, config('oauth.security.rate_limit.decay_minutes', 60) * 60); + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'OAuth authentication failed: ' . $e->getMessage()]); + } + } + + /** + * Redirect to OAuth provider for linking to existing account + */ + public function redirectToProviderForLinking(Request $request, string $provider): RedirectResponse + { + if (!Auth::check()) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'You must be logged in to link OAuth providers.']); + } + + if (!in_array($provider, $this->supportedProviders)) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'Unsupported OAuth provider.']); + } + + // Check if OAuth system is globally enabled + if (!config('oauth.enabled')) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'OAuth authentication is currently disabled.']); + } + + // Check if specific provider is enabled + if (!config("oauth.providers.{$provider}.enabled")) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => ucfirst($provider) . ' OAuth is currently disabled.']); + } + + try { + // Store linking flag in session + $request->session()->put('oauth_linking_mode', true); + $request->session()->put('oauth_linking_user_id', Auth::id()); + $request->session()->flash('debug_message', 'OAuth linking session started for ' . $provider . ' - User ID: ' . Auth::id()); + + // Use the same callback URL as regular OAuth but with linking session data + return Socialite::driver($provider)->redirect(); + } catch (Exception $e) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'OAuth configuration error. Please contact administrator.']); + } + } + + /** + * Handle OAuth callback for linking to existing account + */ + public function handleProviderLinkingCallback(string $provider, Request $request): RedirectResponse + { + if (!in_array($provider, $this->supportedProviders)) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'Unsupported OAuth provider.']); + } + + // Check if we're in linking mode + if (!$request->session()->has('oauth_linking_mode') || !$request->session()->has('oauth_linking_user_id')) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'Invalid linking session.']); + } + + $userId = $request->session()->get('oauth_linking_user_id'); + $user = User::find($userId); + + if (!$user || !Auth::check() || Auth::id() !== $userId) { + $request->session()->forget(['oauth_linking_mode', 'oauth_linking_user_id']); + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'Invalid linking session. Please log in again.']); + } + + try { + // Check if OAuth system is globally enabled + if (!config('oauth.enabled')) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'OAuth authentication is currently disabled.']); + } + + // Check if specific provider is enabled + if (!config("oauth.providers.{$provider}.enabled")) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => ucfirst($provider) . ' OAuth is currently disabled.']); + } + + $oauthUser = Socialite::driver($provider)->user(); + + $request->session()->flash('debug_message', 'OAuth user data retrieved: ID=' . $oauthUser->getId() . ', Email=' . $oauthUser->getEmail()); + + // Check if this OAuth account is already linked to another user + $existingOAuthProvider = UserOAuthProvider::findByProvider($provider, $oauthUser->getId()); + if ($existingOAuthProvider) { + if ($existingOAuthProvider->user_id !== $user->id) { + $request->session()->forget(['oauth_linking_mode', 'oauth_linking_user_id']); + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'This ' . ucfirst($provider) . ' account is already linked to another user.']); + } else { + // OAuth account is already linked to current user + $request->session()->forget(['oauth_linking_mode', 'oauth_linking_user_id']); + return redirect()->route('profile.index') + ->with('message', ucfirst($provider) . ' is already linked to your account!'); + } + } + + // Check if user already has this provider linked + if ($user->hasOAuthProvider($provider)) { + $request->session()->forget(['oauth_linking_mode', 'oauth_linking_user_id']); + return redirect()->route('profile.index') + ->with('message', ucfirst($provider) . ' is already linked to your account!'); + } + + // Link the OAuth provider to the current user + $oauthData = [ + 'id' => $oauthUser->getId(), + 'name' => $oauthUser->getName(), + 'nickname' => $oauthUser->getNickname(), + 'email' => $oauthUser->getEmail(), + 'avatar' => $oauthUser->getAvatar(), + ]; + + try { + $request->session()->flash('debug_message', 'Attempting to link OAuth provider: ' . $provider . ' for user ID: ' . $user->id); + $oauthProvider = $user->linkOAuthProvider($provider, $oauthData); + $request->session()->flash('debug_message', 'OAuth provider linked successfully: Provider=' . $provider . ', DB ID=' . $oauthProvider->id); + } catch (\Exception $linkException) { + $request->session()->flash('debug_message', 'OAuth linking failed: ' . $linkException->getMessage()); + throw $linkException; + } + + // Update user avatar and email if not set + $user->update([ + 'avatar' => $user->avatar ?: $oauthUser->getAvatar(), + 'email' => $user->email ?: $oauthUser->getEmail(), + ]); + + $request->session()->forget(['oauth_linking_mode', 'oauth_linking_user_id']); + + return redirect()->route('profile.index') + ->with('message', ucfirst($provider) . ' has been successfully linked to your account!') + ->with('refresh_oauth', true); + + } catch (Exception $e) { + $request->session()->forget(['oauth_linking_mode', 'oauth_linking_user_id']); + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'OAuth linking failed. Please try again.']); + } + } + + /** + * Get OAuth provider display name + */ + public static function getProviderDisplayName(string $provider): string + { + return match($provider) { + 'google' => 'Google', + 'github' => 'GitHub', + 'discord' => 'Discord', + default => ucfirst($provider) + }; + } + + /** + * Get OAuth provider icon class + */ + public static function getProviderIconClass(string $provider): string + { + return match($provider) { + 'google' => 'fab fa-google', + 'github' => 'fab fa-github', + 'discord' => 'fab fa-discord', + default => 'fas fa-sign-in-alt' + }; + } + + /** + * Check registration restrictions + */ + private function checkRegistrationRestrictions($oauthUser, Request $request) + { + $mode = config('oauth.registration.mode'); + $email = $oauthUser->getEmail(); + + // Check if email is provided + if (!$email) { + return 'Email address is required for registration.'; + } + + // Check blocked domains and emails + $blockedDomains = array_filter(config('oauth.registration.blocked_domains', [])); + $blockedEmails = array_filter(config('oauth.registration.blocked_emails', [])); + + $emailDomain = substr(strrchr($email, '@'), 1); + + if (in_array($email, $blockedEmails) || in_array($emailDomain, $blockedDomains)) { + return 'Registration is not allowed with this email address.'; + } + + // Check max accounts per email + $maxAccounts = config('oauth.security.max_accounts_per_email', 1); + if ($maxAccounts > 0) { + $existingCount = User::where('email', $email)->count(); + if ($existingCount >= $maxAccounts) { + return 'Maximum number of accounts reached for this email address.'; + } + } + + // Mode-specific checks + switch ($mode) { + case 'whitelist': + $allowedDomains = array_filter(config('oauth.registration.allowed_domains', [])); + if (!empty($allowedDomains) && !in_array($emailDomain, $allowedDomains)) { + return 'Registration is only allowed for specific email domains.'; + } + break; + + case 'invite_only': + if (!$request->has('invite_code')) { + return 'An invitation code is required to register.'; + } + + $invite = OAuthInvite::findValidInvite($request->invite_code, $email); + if (!$invite) { + return 'Invalid or expired invitation code.'; + } + break; + } + + return true; + } + + /** + * Get OAuth provider button color + */ + public static function getProviderButtonColor(string $provider): string + { + return match($provider) { + 'google' => 'danger', + 'github' => 'dark', + 'discord' => 'primary', + default => 'secondary' + }; + } + + /** + * Unlink an OAuth provider from the current user + */ + public function unlinkProvider(string $provider, Request $request): RedirectResponse + { + if (!Auth::check()) { + return redirect()->route('auth.login') + ->withErrors(['oauth' => 'You must be logged in to unlink OAuth providers.']); + } + + if (!in_array($provider, $this->supportedProviders)) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'Unsupported OAuth provider.']); + } + + $user = Auth::user(); + + // Check if user has this provider linked + if (!$user->hasOAuthProvider($provider)) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => ucfirst($provider) . ' is not linked to your account.']); + } + + // Prevent unlinking if it's the only authentication method + $linkedProviders = $user->getLinkedProviders(); + $hasPassword = !empty($user->password) && $user->password !== bin2hex(random_bytes(32)); + + if (count($linkedProviders) === 1 && !$hasPassword) { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'Cannot unlink ' . ucfirst($provider) . ' as it is your only authentication method. Please set a password first.']); + } + + // Unlink the provider + if ($user->unlinkOAuthProvider($provider)) { + return redirect()->route('profile.index') + ->with('message', ucfirst($provider) . ' has been successfully unlinked from your account.') + ->with('refresh_oauth', true); + } else { + return redirect()->route('profile.index') + ->withErrors(['oauth' => 'Failed to unlink ' . ucfirst($provider) . '. Please try again.']); + } + } +} \ No newline at end of file diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 0079688..3e21121 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -21,6 +21,8 @@ class Kernel extends HttpKernel \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + \App\Http\Middleware\SecurityHeaders::class, + \App\Http\Middleware\SecurityLogger::class, ]; /** diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 704089a..67c7daf 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -15,7 +15,7 @@ class Authenticate extends Middleware protected function redirectTo($request) { if (! $request->expectsJson()) { - return route('login'); + return route('auth.login'); } } } diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..3bdf925 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,55 @@ +headers->set('Content-Security-Policy', + "default-src 'self'; " . + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://code.highcharts.com; " . + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; " . + "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com; " . + "img-src 'self' data: https:; " . + "connect-src 'self'; " . + "frame-ancestors 'none';" + ); + + // Prevent clickjacking + $response->headers->set('X-Frame-Options', 'DENY'); + + // Prevent MIME type sniffing + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + // Enable XSS protection + $response->headers->set('X-XSS-Protection', '1; mode=block'); + + // Strict Transport Security (HTTPS only) + if ($request->isSecure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + // Referrer Policy + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Permissions Policy + $response->headers->set('Permissions-Policy', + 'geolocation=(), microphone=(), camera=(), payment=(), usb=()' + ); + + return $response; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/SecurityLogger.php b/app/Http/Middleware/SecurityLogger.php new file mode 100644 index 0000000..6b13bae --- /dev/null +++ b/app/Http/Middleware/SecurityLogger.php @@ -0,0 +1,239 @@ +is('login') && $request->isMethod('POST')) { + $this->logAuthenticationAttempt($request, $response); + } + + // Log logout events + if ($request->is('logout') && $request->isMethod('POST')) { + $this->logLogout($request); + } + + // Log failed authorization attempts + if ($response->getStatusCode() === 403) { + $this->logAuthorizationFailure($request); + } + + // Log suspicious activities + $this->logSuspiciousActivity($request, $response); + + return $response; + } + + /** + * Log authentication attempts + */ + private function logAuthenticationAttempt(Request $request, Response $response) + { + $username = $request->input('username'); + $ip = $request->ip(); + $userAgent = $request->userAgent(); + $success = $response->getStatusCode() === 302 && !$response->headers->has('Location') || + (str_contains($response->headers->get('Location', ''), '/') && !str_contains($response->headers->get('Location', ''), 'login')); + + $logData = [ + 'event' => 'authentication_attempt', + 'username' => $username, + 'ip_address' => $ip, + 'user_agent' => $userAgent, + 'success' => $success, + 'timestamp' => now()->toISOString(), + ]; + + if ($success) { + Log::channel('security')->info('Successful login attempt', $logData); + } else { + Log::channel('security')->warning('Failed login attempt', $logData); + } + } + + /** + * Log logout events + */ + private function logLogout(Request $request) + { + $user = Auth::user(); + + Log::channel('security')->info('User logout', [ + 'event' => 'logout', + 'user_id' => $user?->id, + 'username' => $user?->username, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'timestamp' => now()->toISOString(), + ]); + } + + /** + * Log authorization failures + */ + private function logAuthorizationFailure(Request $request) + { + $user = Auth::user(); + + Log::channel('security')->warning('Authorization failure', [ + 'event' => 'authorization_failure', + 'user_id' => $user?->id, + 'username' => $user?->username, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'route' => $request->route()?->getName(), + 'url' => $request->fullUrl(), + 'method' => $request->method(), + 'timestamp' => now()->toISOString(), + ]); + } + + /** + * Log suspicious activities + */ + private function logSuspiciousActivity(Request $request, Response $response) + { + // Skip security checks for legitimate Livewire OAuth requests + if ($this->isLegitimateOAuthRequest($request)) { + return; + } + + $suspiciousPatterns = [ + // SQL injection attempts + '/union.*select/i', + '/select.*from/i', + '/drop.*table/i', + '/insert.*into/i', + '/update.*set/i', + '/delete.*from/i', + + // XSS attempts + '/ +@endsection \ No newline at end of file diff --git a/resources/views/admin/oauth-invites/index.blade.php b/resources/views/admin/oauth-invites/index.blade.php new file mode 100644 index 0000000..20eaac1 --- /dev/null +++ b/resources/views/admin/oauth-invites/index.blade.php @@ -0,0 +1,232 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+
OAuth Invites Management
+
+ + Create Invite + +
+ @csrf + +
+
+
+ +
+ +
+
+
+ + + + @if(request()->hasAny(['status', 'search'])) + + + + @endif +
+
+
+ + + @if(session('success')) + + @endif + + @if($errors->any()) + + @endif + + + @if($invites->count() > 0) +
+ + + + + + + + + + + + + + @foreach($invites as $invite) + @php + $status = 'Active'; + $statusClass = 'success'; + + if ($invite->expires_at < now()) { + $status = 'Expired'; + $statusClass = 'danger'; + } elseif ($invite->single_use && $invite->used_count > 0) { + $status = 'Used'; + $statusClass = 'secondary'; + } elseif ($invite->used_count >= $invite->max_uses) { + $status = 'Used Up'; + $statusClass = 'secondary'; + } + @endphp + + + + + + + + + + @endforeach + +
CodeEmailStatusUsesExpiresCreatedActions
+ {{ substr($invite->code, 0, 12) }}... + + {{ $invite->email ?? 'Any email' }} + {{ $status }} + {{ $invite->used_count }}/{{ $invite->max_uses }} + + {{ $invite->expires_at->format('M j, Y H:i') }} +
+ + ({{ $invite->expires_at->diffForHumans() }}) + +
+
+ {{ $invite->created_at->format('M j, Y H:i') }} + +
+ + + + +
+ @csrf + @method('DELETE') + +
+
+
+
+ + +
+ {{ $invites->links() }} +
+ @else +
+ +
No OAuth invites found
+

Create your first invite to get started.

+ + Create Invite + +
+ @endif +
+
+
+
+
+ + + + + +@endsection \ No newline at end of file diff --git a/resources/views/admin/oauth-invites/show.blade.php b/resources/views/admin/oauth-invites/show.blade.php new file mode 100644 index 0000000..971048f --- /dev/null +++ b/resources/views/admin/oauth-invites/show.blade.php @@ -0,0 +1,310 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+
OAuth Invite Details
+
+ + + Back to Invites + +
+
+ +
+ @php + $status = 'Active'; + $statusClass = 'success'; + + if ($oauthInvite->expires_at < now()) { + $status = 'Expired'; + $statusClass = 'danger'; + } elseif ($oauthInvite->single_use && $oauthInvite->used_count > 0) { + $status = 'Used'; + $statusClass = 'secondary'; + } elseif ($oauthInvite->used_count >= $oauthInvite->max_uses) { + $status = 'Used Up'; + $statusClass = 'secondary'; + } + @endphp + +
+ +
+
+
+
Invite Information
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if($oauthInvite->creator) + + + + + @endif +
Status: + {{ $status }} +
Invite Code: +
+ {{ $oauthInvite->code }} + +
+
Email Restriction: + @if($oauthInvite->email) + {{ $oauthInvite->email }} + @else + Any email allowed + @endif +
Usage Type: + @if($oauthInvite->single_use) + Single Use + @else + Multiple Use + @endif +
Usage Count: + + {{ $oauthInvite->used_count }} + + / {{ $oauthInvite->max_uses }} + + @if($oauthInvite->used_count >= $oauthInvite->max_uses) + Exhausted + @endif +
Created: + {{ $oauthInvite->created_at->format('M j, Y \\a\\t H:i') }} +
+ ({{ $oauthInvite->created_at->diffForHumans() }}) +
Expires: + {{ $oauthInvite->expires_at->format('M j, Y \\a\\t H:i') }} +
+ + ({{ $oauthInvite->expires_at->diffForHumans() }}) + +
Created By:{{ $oauthInvite->creator->username }}
+
+
+
+ + +
+
+
+
Usage History
+
+
+ @if($oauthInvite->usedBy) +
+ + + + + + + + + + + + + + + +
UserEmailUsed At
{{ $oauthInvite->usedBy->username }}{{ $oauthInvite->usedBy->email }} + + {{ $oauthInvite->used_at ? $oauthInvite->used_at->format('M j, H:i') : 'N/A' }} + +
+
+ @else +
+ +

No users have used this invite yet.

+
+ @endif +
+
+
+
+ + +
+
+
+
+
Actions
+
+
+
+ + + + + @if($status === 'Active') + Invite is active and ready to use + @elseif($status === 'Expired') + Invite has expired + @else + Invite has been used up + @endif + +
+
+ @csrf + @method('DELETE') + +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + +@endsection \ No newline at end of file diff --git a/resources/views/admin/user-approvals/index.blade.php b/resources/views/admin/user-approvals/index.blade.php new file mode 100644 index 0000000..65c7a5e --- /dev/null +++ b/resources/views/admin/user-approvals/index.blade.php @@ -0,0 +1,245 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+
OAuth User Approvals
+
+ {{ $pendingCount }} Pending + {{ $approvedCount }} Approved + {{ $rejectedCount }} Rejected +
+
+
+ +
+
+
+ + @if(request('search')) + + @endif + @if(request('provider')) + + @endif +
+
+
+
+ + @if(request('search')) + + @endif + @if(request('status')) + + @endif +
+
+
+
+ + + @if(request('status')) + + @endif + @if(request('provider')) + + @endif +
+
+
+ + @if(session('success')) + + @endif + + @if($errors->any()) + + @endif + + + @if($status === 'pending' && $users->count() > 0) +
+
+ @csrf +
+ + + + +
+
+
+ @endif + + +
+ + + + @if($status === 'pending') + + @endif + + + + + + + @if($status !== 'pending') + + + @endif + + + + + @forelse($users as $user) + + @if($status === 'pending') + + @endif + + + + + + + @if($status !== 'pending') + + + @endif + + + @empty + + + + @endforelse + +
AvatarUsernameEmailProviderStatusCreatedApproved ByApproved AtActions
+ + + @if($user->avatar) + Avatar + @else +
+ +
+ @endif +
{{ $user->username }}{{ $user->email }} + @php + $linkedProviders = $user->oauthProviders->pluck('provider')->toArray(); + @endphp + @if(count($linkedProviders) > 0) + @foreach($linkedProviders as $provider) + {{ ucfirst($provider) }} + @endforeach + @else + Local + @endif + + @if($user->approval_status === 'pending') + Pending + @elseif($user->approval_status === 'approved') + Approved + @elseif($user->approval_status === 'rejected') + Rejected + @endif + {{ $user->created_at->format('M j, Y H:i') }}{{ $user->approvedBy ? $user->approvedBy->username : '-' }}{{ $user->approved_at ? $user->approved_at->format('M j, Y H:i') : '-' }} +
+ + + + @if($user->approval_status === 'pending') +
+ @csrf + +
+
+ @csrf + +
+ @endif +
+
+ +

No users found matching your criteria.

+
+
+ + + @if($users->hasPages()) +
+ {{ $users->links() }} +
+ @endif +
+
+
+
+
+ + +@endsection \ No newline at end of file diff --git a/resources/views/admin/user-approvals/show.blade.php b/resources/views/admin/user-approvals/show.blade.php new file mode 100644 index 0000000..dadbbe9 --- /dev/null +++ b/resources/views/admin/user-approvals/show.blade.php @@ -0,0 +1,241 @@ +@extends('layouts.app') + +@section('content') +
+
+
+
+
+
User Approval Details
+ + Back to List + +
+
+ @if(session('success')) + + @endif + + @if($errors->any()) + + @endif + +
+ +
+
+
+
User Information
+
+
+
+
+ @if($user->avatar) + Avatar + @else +
+ +
+ @endif +
{{ $user->username }}
+ @if($user->approval_status === 'pending') + Pending Approval + @elseif($user->approval_status === 'approved') + Approved + @elseif($user->approval_status === 'rejected') + Rejected + @endif +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if($user->approved_at) + + + + + @endif + @if($user->approvedBy) + + + + + @endif + @if($user->approval_notes) + + + + + @endif +
Username:{{ $user->username }}
Email:{{ $user->email }}
OAuth Providers: + @php + $linkedProviders = $user->oauthProviders; + @endphp + @if($linkedProviders->count() > 0) + @foreach($linkedProviders as $provider) + {{ ucfirst($provider->provider) }} + @endforeach + @else + Local Account + @endif +
Provider Details: + @if($linkedProviders->count() > 0) + @foreach($linkedProviders as $provider) +
+ {{ ucfirst($provider->provider) }}: {{ $provider->provider_id }} + ({{ $provider->provider_email }}) +
+ @endforeach + @else + N/A + @endif +
User Group:{{ $user->usergroup }}
Account Status: + @if($user->is_active) + Active + @else + Inactive + @endif +
Registration Date:{{ $user->created_at->format('F j, Y \\a\\t g:i A') }}
Last Login:{{ $user->last_login ? $user->last_login->format('F j, Y \\a\\t g:i A') : 'Never' }}
Approved At:{{ $user->approved_at->format('F j, Y \\a\\t g:i A') }}
Approved By:{{ $user->approvedBy->username }}
Approval Notes:{{ $user->approval_notes }}
+
+
+
+
+
+ + +
+
+
+
Actions
+
+
+ @if($user->approval_status === 'pending') + +
+ @csrf +
+ + +
+ +
+ + +
+ @csrf +
+ + +
+ +
+ @else +
+

This user has already been {{ $user->approval_status }}.

+ @if($user->approval_status === 'approved') + + @else + + @endif +
+ @endif + + +
+
+ @if($linkedProviders->count() > 0) + @foreach($linkedProviders as $provider) + + Copy {{ ucfirst($provider->provider) }} ID + + @endforeach + @endif + + Copy Email + +
+
+
+ + +
+
+
Account Statistics
+
+
+
+
+
+

{{ floor($user->created_at->diffInDays()) }}

+ Days Since Registration +
+
+
+

{{ $user->last_login ? floor($user->last_login->diffInDays()) : 'Never' }}

+ Days Since Last Login +
+
+
+
+
+
+
+
+
+
+
+ + +@endsection \ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 474b928..c8c5969 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -15,6 +15,14 @@
@enderror + + @error('oauth') +
+ + {{ $message }} + +
+ @enderror
{{ __('Login') }}
@@ -71,6 +79,92 @@ class="form-control @error('password') is-invalid @enderror" name="password"
+ + + @if(config('oauth.enabled') && (config('services.google.client_id') || config('services.github.client_id') || config('services.discord.client_id'))) +
+
+ Or continue with +
+ + + @if(config('oauth.registration.mode') === 'invite_only') +
+ + +
Required for OAuth registration
+
+ @endif + +
+ @if(config('services.google.client_id') && config('oauth.providers.google.enabled')) + + Google + + @endif + @if(config('services.github.client_id') && config('oauth.providers.github.enabled')) + + GitHub + + @endif + @if(config('services.discord.client_id') && config('oauth.providers.discord.enabled')) + + Discord + + @endif +
+ + @if(config('oauth.registration.mode') === 'invite_only') +
+ +
+ @endif + + @if(config('oauth.registration.mode') === 'invite_only') + + @endif +
+ @endif diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index a9cf16a..e5e0abd 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -5,6 +5,7 @@ + {{ config('app.name') }} diff --git a/resources/views/livewire/announcements/announcement-modals.blade.php b/resources/views/livewire/announcements/announcement-modals.blade.php index 0d65de6..f6c1607 100644 --- a/resources/views/livewire/announcements/announcement-modals.blade.php +++ b/resources/views/livewire/announcements/announcement-modals.blade.php @@ -6,7 +6,7 @@ </div> <div class="mb-3"> <strong>Message</strong> - <p>{!! $message !!}</p> + <p>{!! \App\Helpers\HtmlSanitizer::sanitize($message) !!}</p> </div> <div class="mb-3"> <strong>Sound</strong> diff --git a/resources/views/livewire/app/sidebar.blade.php b/resources/views/livewire/app/sidebar.blade.php index 4668580..7b19fde 100644 --- a/resources/views/livewire/app/sidebar.blade.php +++ b/resources/views/livewire/app/sidebar.blade.php @@ -179,6 +179,18 @@ class="list-group-item list-group-item-action py-2 @if(request()->routeIs('setti class="list-group-item list-group-item-action py-2 @if(request()->routeIs('accounts.*')) active @endif"> <i class="fas fa-users fa-fw me-3"></i><span>Groups & Accounts</span> </a> + <a href="/admin/oauth-invites" + + data-mdb-ripple-init + class="list-group-item list-group-item-action py-2 @if(request()->routeIs('admin.oauth-invites.*')) active @endif"> + <i class="fas fa-envelope fa-fw me-3"></i><span>OAuth Invites</span> + </a> + <a href="/admin/user-approvals" + + data-mdb-ripple-init + class="list-group-item list-group-item-action py-2 @if(request()->routeIs('admin.user-approvals.*')) active @endif"> + <i class="fas fa-user-check fa-fw me-3"></i><span>User Approvals</span> + </a> @endcan @endcan diff --git a/resources/views/livewire/commandblocker/commandblocker-modals.blade.php b/resources/views/livewire/commandblocker/commandblocker-modals.blade.php index 47e8b0e..f7ce8a1 100644 --- a/resources/views/livewire/commandblocker/commandblocker-modals.blade.php +++ b/resources/views/livewire/commandblocker/commandblocker-modals.blade.php @@ -27,7 +27,7 @@ </div> <div class="mb-3"> <strong>Custom Message</strong> - <p>{!! $customMessage !!}</p> + <p>{!! \App\Helpers\HtmlSanitizer::sanitize($customMessage) !!}</p> </div> <div class="mb-3"> <label class="bold">Bypass Permission</label> diff --git a/resources/views/livewire/motd/show-motd.blade.php b/resources/views/livewire/motd/show-motd.blade.php index 246387d..c0e7532 100644 --- a/resources/views/livewire/motd/show-motd.blade.php +++ b/resources/views/livewire/motd/show-motd.blade.php @@ -25,10 +25,10 @@ <div class="server-name">Minecraft Server <span id="ping" class="ping">@if(empty($motd->customversion)) 143/200 @else - {!! $motd->customversion !!} + {!! \App\Helpers\HtmlSanitizer::sanitize($motd->customversion) !!} @endif</span> </div> - <span class="preview_motd" wire:modal="motd.text">{!! $motd->text !!}</span> + <span class="preview_motd" wire:modal="motd.text">{!! \App\Helpers\HtmlSanitizer::sanitize($motd->text) !!}</span> </div> </td> <td> diff --git a/resources/views/livewire/profile/show-change-password.blade.php b/resources/views/livewire/profile/show-change-password.blade.php index b45185c..1075c75 100644 --- a/resources/views/livewire/profile/show-change-password.blade.php +++ b/resources/views/livewire/profile/show-change-password.blade.php @@ -6,9 +6,7 @@ <h5 class="alert alert-danger">{{ session('error') }}</h5> @endif - <div class="row gy-4"> - <div class="col-md-6"> - <div class="card"> + <div class="card"> <div class="card-header text-center"> <h5 class="mb-0 text-center"> <strong>@lang('profile.change-password.title')</strong> @@ -40,6 +38,4 @@ class="btn btn-primary">@lang('profile.change-password.buttons.save')</button> </div> </form> </div> - </div> - </div> </div> diff --git a/resources/views/livewire/profile/show-oauth-providers.blade.php b/resources/views/livewire/profile/show-oauth-providers.blade.php new file mode 100644 index 0000000..2035e7b --- /dev/null +++ b/resources/views/livewire/profile/show-oauth-providers.blade.php @@ -0,0 +1,91 @@ +<div> + @if (session('message')) + <h5 class="alert alert-success">{{ session('message') }}</h5> + @endif + @if(session('error')) + <h5 class="alert alert-danger">{{ session('error') }}</h5> + @endif + + <div class="card"> + <div class="card-header text-center"> + <h5 class="mb-0 text-center"> + <strong>OAuth Providers</strong> + </h5> + </div> + <div class="card-body"> + <p class="text-muted mb-4">Link your account with OAuth providers to enable alternative login methods.</p> + + @foreach($supportedProviders as $provider) + @php + $isLinked = collect($linkedProviders)->contains('provider', $provider); + $displayName = $this->getProviderDisplayName($provider); + $icon = $this->getProviderIcon($provider); + @endphp + + <div class="d-flex justify-content-between align-items-center mb-3 p-3 border rounded"> + <div class="d-flex align-items-center"> + <i class="{{ $icon }} me-3" style="font-size: 1.5rem;"></i> + <div> + <h6 class="mb-0">{{ $displayName }}</h6> + <small class="text-muted"> + @if($isLinked) + <span class="text-success"><i class="fas fa-check-circle"></i> Linked</span> + @else + <span class="text-muted">Not linked</span> + @endif + </small> + </div> + </div> + + <div> + @if($isLinked) + <button type="button" class="btn btn-outline-danger btn-sm" + wire:click="confirmUnlink('{{ $provider }}')"> + <i class="fas fa-unlink"></i> Unlink + </button> + @else + <button type="button" class="btn btn-outline-primary btn-sm" + wire:click="linkProvider('{{ $provider }}')"> + <i class="fas fa-link"></i> Link + </button> + @endif + </div> + </div> + @endforeach + </div> + </div> + + <!-- Unlink Confirmation Modal --> + @if($showUnlinkConfirm) + <div class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">Confirm Unlink</h5> + <button type="button" class="btn-close" wire:click="cancelUnlink()"></button> + </div> + <div class="modal-body"> + <p>Are you sure you want to unlink {{ $this->getProviderDisplayName($providerToUnlink) }} from your account?</p> + <div class="alert alert-warning"> + <i class="fas fa-exclamation-triangle"></i> + <strong>Warning:</strong> Make sure you have a password set for your account to maintain access after unlinking OAuth providers. + </div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" wire:click="cancelUnlink()">Cancel</button> + <button type="button" class="btn btn-danger" wire:click="unlinkProvider()">Unlink</button> + </div> + </div> + </div> + </div> + @endif + + @if(session('refresh_oauth')) + <script> + document.addEventListener('DOMContentLoaded', function() { + console.log('Dispatching refreshOAuthProviders event'); + Livewire.dispatch('refreshOAuthProviders'); + }); + </script> + @endif +</div> \ No newline at end of file diff --git a/resources/views/livewire/punishment_templates/punishment-template-modals.blade.php b/resources/views/livewire/punishment_templates/punishment-template-modals.blade.php index 41b3c41..b5d2234 100644 --- a/resources/views/livewire/punishment_templates/punishment-template-modals.blade.php +++ b/resources/views/livewire/punishment_templates/punishment-template-modals.blade.php @@ -18,7 +18,7 @@ </div> <div class="mb-3"> <strong>Reason</strong> - <p>{!! $reason !!}</p> + <p>{!! \App\Helpers\HtmlSanitizer::sanitize($reason) !!}</p> </div> <x-slot name="footer"> <button type="button" class="btn btn-secondary" data-mdb-dismiss="modal">Close</button> diff --git a/resources/views/livewire/punishments/punishment-modals.blade.php b/resources/views/livewire/punishments/punishment-modals.blade.php index 516be28..5147602 100644 --- a/resources/views/livewire/punishments/punishment-modals.blade.php +++ b/resources/views/livewire/punishments/punishment-modals.blade.php @@ -47,7 +47,7 @@ @endcan <div class="mb-3"> <strong>Reason</strong> - <p>{!! $reason !!}</p> + <p>{!! \App\Helpers\HtmlSanitizer::sanitize($reason) !!}</p> </div> <div class="mb-3"> <strong>Silent</strong> diff --git a/resources/views/livewire/servers/server-modals.blade.php b/resources/views/livewire/servers/server-modals.blade.php index 1a89314..7c0c5bb 100644 --- a/resources/views/livewire/servers/server-modals.blade.php +++ b/resources/views/livewire/servers/server-modals.blade.php @@ -6,7 +6,7 @@ </div> <div class="mb-3"> <strong>Display Name</strong> - <p>{!! $displayname !!}</p> + <p>{!! \App\Helpers\HtmlSanitizer::sanitize($displayname) !!}</p> </div> <div class="mb-3"> <strong>IP Address</strong> @@ -18,7 +18,7 @@ </div> <div class="mb-3"> <strong>MOTD</strong> - <p>{!! $motd !!}</p> + <p>{!! \App\Helpers\HtmlSanitizer::sanitize($motd) !!}</p> </div> <div class="mb-3"> <strong>Allowed Versions</strong> diff --git a/resources/views/livewire/tickets/show-ticket.blade.php b/resources/views/livewire/tickets/show-ticket.blade.php index b82d616..b139408 100644 --- a/resources/views/livewire/tickets/show-ticket.blade.php +++ b/resources/views/livewire/tickets/show-ticket.blade.php @@ -107,7 +107,7 @@ class="{{$ticketPriority->iconColorClass()}} fa-solid fa-circle-exclamation"></i </div> </div> <div class="card-body"> - {!! $ticketMessage->message !!} + {!! \App\Helpers\HtmlSanitizer::sanitize($ticketMessage->message) !!} </div> </div> <hr class="hr"> @@ -124,7 +124,7 @@ class="{{$ticketPriority->iconColorClass()}} fa-solid fa-circle-exclamation"></i </div> </div> <div class="card-body"> - {!! $ticket->message !!} + {!! \App\Helpers\HtmlSanitizer::sanitize($ticket->message) !!} </div> </div> </div> diff --git a/resources/views/profile/index.blade.php b/resources/views/profile/index.blade.php index 4ae2442..ad33e53 100644 --- a/resources/views/profile/index.blade.php +++ b/resources/views/profile/index.blade.php @@ -2,8 +2,13 @@ @section('content') - <div> - @livewire('profile.show-change-password') + <div class="row gy-4"> + <div class="col-md-6"> + @livewire('profile.show-change-password') + </div> + <div class="col-md-6"> + @livewire('profile.show-oauth-providers') + </div> </div> @endsection diff --git a/routes/web.php b/routes/web.php index 31fcb1b..547c18b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ use App\Http\Controllers\SettingsController; use App\Http\Controllers\Webpanel\AccountsController; use App\Http\Controllers\Webpanel\AuthenticationController; +use App\Http\Controllers\Webpanel\OAuthController; use Illuminate\Support\Facades\Route; /* @@ -50,6 +51,17 @@ Route::post('/logout', 'logout')->name('logout'); }); +// OAuth Routes +Route::controller(OAuthController::class)->prefix('auth')->group(function () { + Route::get('/{provider}', 'redirectToProvider')->name('oauth.redirect'); + Route::get('/{provider}/callback', 'handleProviderCallback')->name('oauth.callback'); + + // OAuth Provider Linking Routes (for authenticated users) + Route::middleware('auth')->group(function () { + Route::get('/link/{provider}', 'redirectToProviderForLinking')->name('oauth.link'); + }); +}); + Route::resource('servers', ServersController::class); Route::resource('announcements', AnnouncementsController::class); Route::resource('punishments', PunishmentsController::class); @@ -89,3 +101,24 @@ Route::get('/', 'index')->name('tickets'); Route::get('/{ticket}', 'ticket')->name('tickets.ticket'); }); + +// OAuth Invite Management Routes +Route::prefix('admin/oauth-invites')->controller(App\Http\Controllers\Admin\OAuthInviteController::class)->group(function () { + Route::get('/', 'index')->name('admin.oauth-invites.index'); + Route::get('/create', 'create')->name('admin.oauth-invites.create'); + Route::post('/', 'store')->name('admin.oauth-invites.store'); + Route::get('/{oauthInvite}', 'show')->name('admin.oauth-invites.show'); + Route::delete('/{oauthInvite}', 'destroy')->name('admin.oauth-invites.destroy'); + Route::post('/cleanup', 'cleanup')->name('admin.oauth-invites.cleanup'); + Route::get('/{oauthInvite}/url', 'generateUrl')->name('admin.oauth-invites.url'); +}); + +// User Approval Management Routes +Route::prefix('admin/user-approvals')->controller(App\Http\Controllers\Admin\UserApprovalController::class)->group(function () { + Route::get('/', 'index')->name('admin.user-approvals.index'); + Route::get('/{user}', 'show')->name('admin.user-approvals.show'); + Route::post('/{user}/approve', 'approve')->name('admin.user-approvals.approve'); + Route::post('/{user}/reject', 'reject')->name('admin.user-approvals.reject'); + Route::post('/bulk-approve', 'bulkApprove')->name('admin.user-approvals.bulk-approve'); + Route::post('/bulk-reject', 'bulkReject')->name('admin.user-approvals.bulk-reject'); +});