Skip to content

Commit 11d9c40

Browse files
committed
Added the provision to store last acctivity on the database
1 parent 1e8c4e5 commit 11d9c40

File tree

4 files changed

+116
-61
lines changed

4 files changed

+116
-61
lines changed

README.md

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,11 @@ This will create a `config/security-policies.php` file with default values. You
7474

7575
### Session
7676

77-
| Key | Type | Default | Description |
78-
| ------------------------------ | ------------------- | ------- | -------------------------------------------- |
79-
| `session.idle_timeout_minutes` | integer | `30` | Minutes of inactivity before forcing logout. |
80-
| `session.redirect_on_idle_to` | string (route name) | `login` | Route to redirect to after idle timeout. |
77+
| Key | Type | Default | Description |
78+
| ------------------------------ | ------------------- | --------- | --------------------------------------------------------------- |
79+
| `session.idle_timeout_minutes` | integer | `30` | Minutes of inactivity before forcing logout. |
80+
| `session.redirect_on_idle_to` | string (route name) | `login` | Route to redirect to after idle timeout. |
81+
| `session.last_activity_store` | string | `session` | Where to store last activity timestamp: 'session' or 'database' |
8182

8283
### MFA
8384

@@ -105,25 +106,26 @@ This will create a `config/security-policies.php` file with default values. You
105106

106107
### Password
107108

108-
| Key | Type | Default | Description |
109-
| --------------------------------- | ------------------- | ------------------ | ------------------------------------------------------------------------------------------------- |
110-
| `password.min_length` | integer | `12` | Minimum password length. |
111-
| `password.min_digits` | integer | `1` | Minimum digits required. |
112-
| `password.min_symbols` | integer | `1` | Minimum symbols required. |
113-
| `password.min_lowercase` | integer | `1` | Minimum lowercase letters required. |
114-
| `password.min_uppercase` | integer | `1` | Minimum uppercase letters required. |
109+
| Key | Type | Default | Description |
110+
| --------------------------------- | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------- |
111+
| `password.min_length` | integer | `12` | Minimum password length. |
112+
| `password.min_digits` | integer | `1` | Minimum digits required. |
113+
| `password.min_symbols` | integer | `1` | Minimum symbols required. |
114+
| `password.min_lowercase` | integer | `1` | Minimum lowercase letters required. |
115+
| `password.min_uppercase` | integer | `1` | Minimum uppercase letters required. |
115116
| `password.allowed_symbols` | string | `!@#$%^&*()_+-={}[]:;'"<>,.?/\\|~` | Allowed symbol set counted by StrongPassword and used to reject disallowed characters. |
116-
| `password.expire_days` | integer | `90` | Force password change after X days. |
117-
| `password.history` | integer | `5` | Disallow reuse of last X passwords. |
118-
| `password.require_history` | bool | `false` | If true, user must have at least one password history entry; otherwise redirected to change page. |
119-
| `password.redirect_on_expired_to` | string (route name) | `password.request` | Route to redirect to when password is expired or when history is required but missing. |
117+
| `password.expire_days` | integer | `90` | Force password change after X days. |
118+
| `password.history` | integer | `5` | Disallow reuse of last X passwords. |
119+
| `password.require_history` | bool | `false` | If true, user must have at least one password history entry; otherwise redirected to change page. |
120+
| `password.redirect_on_expired_to` | string (route name) | `password.request` | Route to redirect to when password is expired or when history is required but missing. |
120121

121122
### User Columns
122123

123-
| Key | Type | Default | Description |
124-
| ---------------------------------- | ------ | --------------------- | ------------------------------------------------------------- |
125-
| `user_columns.last_mfa_at` | string | `last_mfa_at` | User model column that stores when MFA was last completed. |
126-
| `user_columns.password_changed_at` | string | `password_changed_at` | User model column that stores when password was last changed. |
124+
| Key | Type | Default | Description |
125+
| ---------------------------------- | ------ | --------------------- | -------------------------------------------------------------------------------------- |
126+
| `user_columns.last_mfa_at` | string | `last_mfa_at` | User model column that stores when MFA was last completed. |
127+
| `user_columns.password_changed_at` | string | `password_changed_at` | User model column that stores when password was last changed. |
128+
| `user_columns.last_activity_at` | string | `last_active_at` | User model column that stores the last activity timestamp when using database storage. |
127129

128130
Publish migrations:
129131

@@ -205,9 +207,25 @@ php artisan vendor:publish --provider="NagibMahfuj\LaravelSecurityPolicies\Larav
205207
- `password_histories`: user_id, password_hash, timestamps
206208
- `mfa_challenges`: user_id, code, expires_at, consumed_at, attempts, timestamps
207209
- `trusted_devices`: user_id, device_fingerprint, user_agent, ip_hash, verified_at, last_seen_at, timestamps
208-
- Alters `users` table (defaults): `last_mfa_at`, `password_changed_at`
210+
- Alters `users` table (defaults): `last_mfa_at`, `password_changed_at`, `last_active_at`
209211
- You may rename these columns in your own migrations and set the names via `user_columns.*` in the config.
210212

213+
### Enabling Database Storage for Last Activity
214+
215+
To use database storage for last activity tracking:
216+
217+
1. Ensure your `users` table has a timestamp column for last activity (default: `last_active_at`). The published migration will add this if needed.
218+
2. Set the following in `config/security-policies.php`:
219+
```php
220+
'session' => [
221+
'last_activity_store' => 'database', // or 'session' for the default behavior
222+
],
223+
'user_columns' => [
224+
'last_activity_at' => 'last_active_at', // customize column name if needed
225+
],
226+
```
227+
3. The middleware will now track last activity in the database instead of the session.
228+
211229
## Events & Listeners
212230

213231
- Listens to `Illuminate\Auth\Events\PasswordReset`

config/security-policies.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
'session' => [
55
'idle_timeout_minutes' => 30,
66
'redirect_on_idle_to' => 'login',
7+
'last_activity_store' => 'session',
78
],
89
'mfa' => [
910
'enabled' => true,
@@ -32,5 +33,6 @@
3233
'user_columns' => [
3334
'last_mfa_at' => 'last_mfa_at',
3435
'password_changed_at' => 'password_changed_at',
36+
'last_activity_at' => 'last_active_at',
3537
],
3638
];
Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,52 @@
11
<?php
22

3-
use Illuminate\Database\Migrations\Migration;
4-
use Illuminate\Database\Schema\Blueprint;
53
use Illuminate\Support\Facades\Schema;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Database\Migrations\Migration;
66

77
return new class extends Migration {
8-
public function up(): void
9-
{
10-
$lastMfaCol = config('security-policies.user_columns.last_mfa_at', 'last_mfa_at');
11-
$pwdChangedCol = config('security-policies.user_columns.password_changed_at', 'password_changed_at');
8+
public function up(): void
9+
{
10+
$lastMfaCol = config('security-policies.user_columns.last_mfa_at', 'last_mfa_at');
11+
$pwdChangedCol = config('security-policies.user_columns.password_changed_at', 'password_changed_at');
12+
$lastActivityCol = config('security-policies.user_columns.last_activity_at', 'last_active_at');
13+
14+
Schema::table('users', function (Blueprint $table) use ($lastMfaCol, $pwdChangedCol, $lastActivityCol) {
15+
if (!Schema::hasColumn('users', $lastMfaCol)) {
16+
$table->timestamp($lastMfaCol)->nullable()->after('remember_token');
17+
}
18+
if (!Schema::hasColumn('users', $pwdChangedCol)) {
19+
// place after last MFA column when possible
20+
$afterCol = Schema::hasColumn('users', $lastMfaCol) ? $lastMfaCol : 'remember_token';
21+
$table->timestamp($pwdChangedCol)->nullable()->after($afterCol);
22+
}
23+
if (!Schema::hasColumn('users', $lastActivityCol)) {
24+
$afterCol2 = Schema::hasColumn('users', $pwdChangedCol) ? $pwdChangedCol : (Schema::hasColumn('users', $lastMfaCol) ? $lastMfaCol : 'remember_token');
25+
$table->timestamp($lastActivityCol)->nullable()->after($afterCol2);
26+
}
27+
});
28+
}
1229

13-
Schema::table('users', function (Blueprint $table) use ($lastMfaCol, $pwdChangedCol) {
14-
if (!Schema::hasColumn('users', $lastMfaCol)) {
15-
$table->timestamp($lastMfaCol)->nullable()->after('remember_token');
16-
}
17-
if (!Schema::hasColumn('users', $pwdChangedCol)) {
18-
// place after last MFA column when possible
19-
$afterCol = Schema::hasColumn('users', $lastMfaCol) ? $lastMfaCol : 'remember_token';
20-
$table->timestamp($pwdChangedCol)->nullable()->after($afterCol);
21-
}
22-
});
23-
}
24-
public function down(): void
25-
{
26-
$lastMfaCol = config('security-policies.user_columns.last_mfa_at', 'last_mfa_at');
27-
$pwdChangedCol = config('security-policies.user_columns.password_changed_at', 'password_changed_at');
30+
public function down(): void
31+
{
32+
$lastMfaCol = config('security-policies.user_columns.last_mfa_at', 'last_mfa_at');
33+
$pwdChangedCol = config('security-policies.user_columns.password_changed_at', 'password_changed_at');
34+
$lastActivityCol = config('security-policies.user_columns.last_activity_at', 'last_active_at');
2835

29-
Schema::table('users', function (Blueprint $table) use ($lastMfaCol, $pwdChangedCol) {
30-
$drops = [];
31-
if (Schema::hasColumn('users', $lastMfaCol)) {
32-
$drops[] = $lastMfaCol;
33-
}
34-
if (Schema::hasColumn('users', $pwdChangedCol)) {
35-
$drops[] = $pwdChangedCol;
36-
}
37-
if (!empty($drops)) {
38-
$table->dropColumn($drops);
39-
}
40-
});
41-
}
36+
Schema::table('users', function (Blueprint $table) use ($lastMfaCol, $pwdChangedCol, $lastActivityCol) {
37+
$drops = [];
38+
if (Schema::hasColumn('users', $lastMfaCol)) {
39+
$drops[] = $lastMfaCol;
40+
}
41+
if (Schema::hasColumn('users', $pwdChangedCol)) {
42+
$drops[] = $pwdChangedCol;
43+
}
44+
if (Schema::hasColumn('users', $lastActivityCol)) {
45+
$drops[] = $lastActivityCol;
46+
}
47+
if (!empty($drops)) {
48+
$table->dropColumn($drops);
49+
}
50+
});
51+
}
4252
};

src/Http/Middleware/IdleTimeoutMiddleware.php

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Closure;
66
use Illuminate\Http\Request;
7+
use Illuminate\Support\Carbon;
78
use Illuminate\Support\Facades\Auth;
89

910
class IdleTimeoutMiddleware
@@ -12,14 +13,38 @@ public function handle(Request $request, Closure $next)
1213
{
1314
$timeout = (int) config('security-policies.session.idle_timeout_minutes', 30);
1415
if ($timeout > 0 && Auth::check()) {
15-
$last = (int) $request->session()->get('last_activity_ts', time());
16-
if ((time() - $last) > ($timeout * 60)) {
17-
Auth::logout();
18-
$request->session()->invalidate();
19-
$request->session()->regenerateToken();
20-
return redirect()->route(config('security-policies.session.redirect_on_idle_to', 'login'))->with('error', 'You have been logged out due to inactivity.');
16+
$store = (string) config('security-policies.session.last_activity_store', 'session');
17+
$nowTs = time();
18+
19+
if ($store === 'database') {
20+
$user = Auth::user();
21+
$col = config('security-policies.user_columns.last_activity_at', 'last_active_at');
22+
$lastAt = $user->{$col} ?? null;
23+
$lastTs = $lastAt ? Carbon::parse($lastAt)->timestamp : $nowTs;
24+
25+
if (($nowTs - $lastTs) > ($timeout * 60)) {
26+
Auth::logout();
27+
$request->session()->invalidate();
28+
$request->session()->regenerateToken();
29+
return redirect()->route(config('security-policies.session.redirect_on_idle_to', 'login'))
30+
->with('error', 'You have been logged out due to inactivity.');
31+
}
32+
33+
// Throttle DB writes to at most once per minute
34+
if (($nowTs - $lastTs) >= 60) {
35+
$user->forceFill([$col => Carbon::now()])->save();
36+
}
37+
} else {
38+
$last = (int) $request->session()->get('last_activity_ts', $nowTs);
39+
if (($nowTs - $last) > ($timeout * 60)) {
40+
Auth::logout();
41+
$request->session()->invalidate();
42+
$request->session()->regenerateToken();
43+
return redirect()->route(config('security-policies.session.redirect_on_idle_to', 'login'))
44+
->with('error', 'You have been logged out due to inactivity.');
45+
}
46+
$request->session()->put('last_activity_ts', $nowTs);
2147
}
22-
$request->session()->put('last_activity_ts', time());
2348
}
2449
return $next($request);
2550
}

0 commit comments

Comments
 (0)