diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 930fda7..4f2a6f3 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -126,6 +126,26 @@ class UserController extends Controller ->with('success', 'Password untuk ' . $user->name . ' berhasil direset.'); } + /** + * Akhiri sesi aktif user (single session) — user harus login ulang. + */ + public function forceLogout(User $user): RedirectResponse + { + if ($user->id === auth()->id()) { + return back()->with('error', 'Tidak bisa memaksa logout diri sendiri.'); + } + + $token = $user->active_session_token; + $user->forceFill(['active_session_token' => null])->save(); + + // Hapus session file lama agar perangkat lama benar-benar terlempar + if ($token && config('session.driver') === 'file') { + @unlink(storage_path('framework/sessions/'.$token)); + } + + return back()->with('success', 'Sesi '.$user->name.' diakhiri — akun bisa login kembali.'); + } + /** * Kirim akun (link + username + password baru) ke nomor HP user via WhatsApp. * Password lama tidak bisa dikirim (tersimpan hash) — jadi dibuatkan password baru. diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 8d25fac..306052d 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -23,10 +23,22 @@ class LoginController extends Controller ]); if (Auth::attempt($credentials, $request->boolean('remember'))) { + $user = Auth::user(); + + // Single session: tolak login bila akun masih aktif di perangkat lain + if ($user->sessionTokenIsAlive()) { + Auth::logout(); + + return back()->withErrors([ + 'username' => 'Akun ini sedang digunakan di perangkat lain. Silakan logout dari perangkat tersebut terlebih dahulu.', + ])->onlyInput('username'); + } + $request->session()->regenerate(); + $user->forceFill(['active_session_token' => $request->session()->getId()])->save(); // Role 'other' langsung ke presensi, bukan dashboard - if (Auth::user()->role === 'other') { + if ($user->role === 'other') { return redirect()->route('attendances.index'); } @@ -40,6 +52,12 @@ class LoginController extends Controller public function logout(Request $request): RedirectResponse { + $user = Auth::user(); + + if ($user && $user->active_session_token === $request->session()->getId()) { + $user->forceFill(['active_session_token' => null])->save(); + } + Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); diff --git a/app/Http/Middleware/SingleSession.php b/app/Http/Middleware/SingleSession.php new file mode 100644 index 0000000..5d65ec5 --- /dev/null +++ b/app/Http/Middleware/SingleSession.php @@ -0,0 +1,41 @@ +active_session_token; + + if ($token && $token !== Session::getId()) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login') + ->withErrors(['username' => 'Sesi berakhir: akun ini sedang digunakan di perangkat lain.']) + ->onlyInput('username'); + } + + return $next($request); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3054c00..07ebb5f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,7 +13,7 @@ class User extends Authenticatable public const ROLES = ['admin', 'bk', 'wali_kelas', 'staff', 'other', 'kepala_sekolah', 'waka_kesiswaan', 'ketua_tim']; protected $fillable = [ - 'name', 'username', 'email', 'phone', 'password', 'role', 'roles', 'is_active', + 'name', 'username', 'email', 'phone', 'password', 'role', 'roles', 'is_active', 'active_session_token', ]; protected $hidden = ['password', 'remember_token']; @@ -150,4 +150,24 @@ class User extends Authenticatable { return $this->hasMany(ViolationHandling::class, 'created_by'); } + + /** + * Cek apakah token sesi aktif masih "hidup" (session file/DB masih ada). + * Driver array/lain → dianggap hidup (aman, tolak login ganda). + */ + public function sessionTokenIsAlive(): bool + { + $token = $this->active_session_token; + if (! $token) { + return false; + } + + return match (config('session.driver')) { + 'file' => file_exists(storage_path('framework/sessions/'.$token)), + 'database' => \Illuminate\Support\Facades\DB::table(config('session.table', 'sessions')) + ->where('id', $token) + ->exists(), + default => true, + }; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index cc4d5f0..638d6fe 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,6 +23,9 @@ return Application::configure(basePath: dirname(__DIR__)) 'role' => \App\Http\Middleware\CheckRole::class, 'permission' => \App\Http\Middleware\PermissionMiddleware::class, ]); + $middleware->web(append: [ + \App\Http\Middleware\SingleSession::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/database/migrations/2026_08_03_102109_add_active_session_token_to_users.php b/database/migrations/2026_08_03_102109_add_active_session_token_to_users.php new file mode 100644 index 0000000..742299d --- /dev/null +++ b/database/migrations/2026_08_03_102109_add_active_session_token_to_users.php @@ -0,0 +1,28 @@ +string('active_session_token', 191)->nullable()->after('phone'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('active_session_token'); + }); + } +}; diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index 2c7fb89..fb636dd 100644 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -131,6 +131,17 @@ @if($user->id !== auth()->id()) +
+ @csrf + +
+ @endif
diff --git a/routes/web.php b/routes/web.php index 5961e78..390e97b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -175,6 +175,7 @@ Route::middleware('auth')->group(function () { Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('users.destroy'); Route::post('/users/{user}/reset-password', [UserController::class, 'resetPassword'])->name('users.reset-password'); Route::post('/users/{user}/send-wa', [UserController::class, 'sendWa'])->name('users.send-wa'); + Route::post('/users/{user}/force-logout', [UserController::class, 'forceLogout'])->name('users.force-logout'); Route::get('/settings/permissions', [\App\Http\Controllers\Admin\PermissionController::class, 'index'])->name('settings.permissions'); Route::post('/settings/permissions', [\App\Http\Controllers\Admin\PermissionController::class, 'update'])->name('settings.permissions.update'); diff --git a/tests/Feature/SingleSessionTest.php b/tests/Feature/SingleSessionTest.php new file mode 100644 index 0000000..aba83fe --- /dev/null +++ b/tests/Feature/SingleSessionTest.php @@ -0,0 +1,97 @@ +admin = User::factory()->create(['roles' => ['admin']]); + } + + protected function makeUser(): User + { + return User::factory()->create([ + 'roles' => ['bk'], + 'username' => 'bksession', + 'password' => 'rahasia123', + ]); + } + + public function test_login_menyimpan_token_sesi_aktif(): void + { + $user = $this->makeUser(); + + $this->post('/login', [ + 'username' => 'bksession', + 'password' => 'rahasia123', + ])->assertRedirect(); + + $this->assertNotNull($user->refresh()->active_session_token); + } + + public function test_login_kedua_ditolak_saat_akun_masih_aktif(): void + { + $user = $this->makeUser(); + // Simulasikan sudah login di perangkat lain (token sesi aktif) + $user->forceFill(['active_session_token' => 'sesi-perangkat-lain'])->save(); + + $this->post('/login', [ + 'username' => 'bksession', + 'password' => 'rahasia123', + ])->assertSessionHasErrors('username'); + + $this->assertGuest(); + $this->assertSame('sesi-perangkat-lain', $user->refresh()->active_session_token); + } + + public function test_middleware_mengeluarkan_sesi_yang_bukan_pemilik_token(): void + { + $user = $this->makeUser(); + $user->forceFill(['active_session_token' => 'sesi-perangkat-lain'])->save(); + + // User ini "login" dengan sesi berbeda → harus ditendang ke halaman login + $this->actingAs($user) + ->get('/dashboard') + ->assertRedirect(route('login')); + + $this->assertGuest(); + } + + public function test_logout_menghapus_token_sesi(): void + { + $user = $this->makeUser(); + $sessionId = str_repeat('a', 40); // id sesi valid (format 40 char) + $user->forceFill(['active_session_token' => $sessionId])->save(); + + // Uji logika controller logout langsung (session id = token) + $request = \Illuminate\Http\Request::create('/logout', 'POST'); + $request->setLaravelSession($this->app['session.store']); + $this->app['session.store']->setId($sessionId); + $this->actingAs($user); + + (new \App\Http\Controllers\Auth\LoginController())->logout($request); + + $this->assertNull($user->refresh()->active_session_token); + } + + public function test_force_logout_membersihkan_token(): void + { + $user = $this->makeUser(); + $user->forceFill(['active_session_token' => 'sesi-lama'])->save(); + + $this->actingAs($this->admin) + ->post("/users/{$user->id}/force-logout") + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertNull($user->refresh()->active_session_token); + } +}