Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
209 changes: 209 additions & 0 deletions app/Console/Commands/ManageOAuthInvites.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php

namespace App\Console\Commands;

use App\Models\OAuthInvite;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;

class ManageOAuthInvites extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'oauth:invites
{action : Action to perform (create, list, revoke, cleanup)}
{--email= : Email address for the invite (required for create)}
{--expires= : Expiration time in hours (default: 168 = 7 days)}
{--single-use : Make the invite single-use only}
{--max-uses= : Maximum number of uses (default: 1)}
{--code= : Specific invite code (for revoke action)}
{--expired : Include expired invites in list}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Manage OAuth invitation codes for restricted registration';

/**
* Execute the console command.
*/
public function handle()
{
$action = $this->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;
}
}
117 changes: 117 additions & 0 deletions app/Helpers/HtmlSanitizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace App\Helpers;

class HtmlSanitizer
{
/**
* Sanitize HTML content while preserving safe tags and Minecraft formatting
*
* @param string|null $content
* @return string
*/
public static function sanitize(?string $content): string
{
if (empty($content)) {
return '';
}

// Allow safe HTML tags commonly used in MOTD and messages
$allowedTags = [
'b', 'i', 'u', 'strong', 'em', 'span', 'div', 'p', 'br',
'font', 'color', 'style' // For Minecraft formatting
];

// Allow safe attributes
$allowedAttributes = [
'class', 'style', 'color', 'size', 'face' // For styling and Minecraft formatting
];

// Strip dangerous tags and attributes
$content = strip_tags($content, '<' . implode('><', $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[^>]*>.*?<\/script>/is', '', $content);
$content = preg_replace('/<style[^>]*>.*?<\/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' => '<span style="color: #000000">',
'§1' => '<span style="color: #0000AA">',
'§2' => '<span style="color: #00AA00">',
'§3' => '<span style="color: #00AAAA">',
'§4' => '<span style="color: #AA0000">',
'§5' => '<span style="color: #AA00AA">',
'§6' => '<span style="color: #FFAA00">',
'§7' => '<span style="color: #AAAAAA">',
'§8' => '<span style="color: #555555">',
'§9' => '<span style="color: #5555FF">',
'§a' => '<span style="color: #55FF55">',
'§b' => '<span style="color: #55FFFF">',
'§c' => '<span style="color: #FF5555">',
'§d' => '<span style="color: #FF55FF">',
'§e' => '<span style="color: #FFFF55">',
'§f' => '<span style="color: #FFFFFF">',
'§l' => '<strong>',
'§o' => '<em>',
'§n' => '<u>',
'§r' => '</span></strong></em></u>'
];

// 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;
}
}
Loading