refactor: harden auth, CLI input, and scheduled-log viewer (#9672)
This commit is contained in:
commit
38881df66f
6 changed files with 212 additions and 18 deletions
|
|
@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
|
|||
public function handle()
|
||||
{
|
||||
$date = $this->option('date') ?: now()->format('Y-m-d');
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
$logPaths = $this->getLogPaths($date);
|
||||
|
||||
if (empty($logPaths)) {
|
||||
|
|
@ -49,17 +54,19 @@ public function handle()
|
|||
$this->line('');
|
||||
|
||||
if (count($logPaths) === 1) {
|
||||
$logPath = $logPaths[0];
|
||||
$logPath = escapeshellarg($logPaths[0]);
|
||||
if ($filters) {
|
||||
passthru("tail -f {$logPath} | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -f {$logPath}");
|
||||
}
|
||||
} else {
|
||||
// Multiple files - use multitail or tail with process substitution
|
||||
$logPathsStr = implode(' ', $logPaths);
|
||||
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
|
||||
if ($filters) {
|
||||
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -f {$logPathsStr}");
|
||||
}
|
||||
|
|
@ -68,20 +75,23 @@ public function handle()
|
|||
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
|
||||
$this->line('');
|
||||
|
||||
$escapedLines = escapeshellarg((string) $lines);
|
||||
if (count($logPaths) === 1) {
|
||||
$logPath = $logPaths[0];
|
||||
$logPath = escapeshellarg($logPaths[0]);
|
||||
if ($filters) {
|
||||
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -n {$lines} {$logPath}");
|
||||
passthru("tail -n {$escapedLines} {$logPath}");
|
||||
}
|
||||
} else {
|
||||
// Multiple files - concatenate and sort by timestamp
|
||||
$logPathsStr = implode(' ', $logPaths);
|
||||
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
|
||||
if ($filters) {
|
||||
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
|
||||
$escapedFilters = escapeshellarg($filters);
|
||||
passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
|
||||
} else {
|
||||
passthru("tail -n {$lines} {$logPathsStr} | sort");
|
||||
passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
|
@ -39,9 +39,29 @@ public function verify()
|
|||
return view('auth.verify-email');
|
||||
}
|
||||
|
||||
public function email_verify(EmailVerificationRequest $request)
|
||||
public function email_verify(Request $request)
|
||||
{
|
||||
$request->fulfill();
|
||||
if (! $request->hasValidSignature()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect(RouteServiceProvider::HOME);
|
||||
}
|
||||
|
|
@ -94,10 +114,6 @@ public function link()
|
|||
} else {
|
||||
$team = $user->teams()->first();
|
||||
}
|
||||
if (is_null(data_get($user, 'email_verified_at'))) {
|
||||
$user->email_verified_at = now();
|
||||
$user->save();
|
||||
}
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ public function sendVerificationEmail()
|
|||
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
|
||||
[
|
||||
'id' => $this->getKey(),
|
||||
'hash' => sha1($this->getEmailForVerification()),
|
||||
'hash' => hash('sha256', $this->getEmailForVerification()),
|
||||
]
|
||||
);
|
||||
$mail->view('emails.email-verification', [
|
||||
|
|
|
|||
73
tests/Feature/EmailVerificationHashTest.php
Normal file
73
tests/Feature/EmailVerificationHashTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('email verification hash', function () {
|
||||
test('sha256 hash is accepted and marks the user verified', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'verify-me@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('legacy sha1 hash is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'legacy-sha1@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => sha1($user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertStatus(403);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('tampered signature is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'tampered@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$tampered = $url.'x';
|
||||
|
||||
$this->actingAs($user)->get($tampered)->assertStatus(403);
|
||||
});
|
||||
});
|
||||
60
tests/Feature/LinkLoginEmailVerificationTest.php
Normal file
60
tests/Feature/LinkLoginEmailVerificationTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('invitation link login', function () {
|
||||
test('does not auto-verify the email address', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('still logs the user in', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee2@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
expect(auth()->id())->toBe($user->id);
|
||||
});
|
||||
});
|
||||
35
tests/Feature/ScheduledLogsCommandInputTest.php
Normal file
35
tests/Feature/ScheduledLogsCommandInputTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\ViewScheduledLogs;
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('logs:scheduled --date option', function () {
|
||||
test('rejects a malformed date and exits before touching the shell', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn'])
|
||||
->expectsOutputToContain('Invalid date format')
|
||||
->assertExitCode(ViewScheduledLogs::INVALID);
|
||||
|
||||
expect(file_exists('/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('accepts a well-formed date', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01'])
|
||||
->assertExitCode(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue