Session Safety + SSO Logout
Shared middleware — fixes a latent session-driver risk and adds Moodle-driven portal logout via moodle.active.
Feature Analysis — 6W Framework
What
- Changed
SESSION_DRIVERfromdatabasetoarray— no DB session-table interaction possible for a REST API. - Added
ValidateMoodleUserStatusmiddleware that re-checksmdldf_userstatus on every authenticated request. - Added
MoodleSessionread-only model wrappingmdldf_sessionsas shared infrastructure for future SSO session lookups. - Added boolean casts for
deleted,suspended,confirmedtoMoodleUser.
When
- Middleware fires on every authenticated API request after
auth:sanctumresolves the user. - Status is checked from
Cache::rememberwith a 60-second TTL — real DB hit only on cache miss. - Revocation happens immediately when a suspended or deleted status is detected.
- Admin actions propagate to the portal within ≤ 60 seconds.
Why
- Session-driver risk:
SESSION_DRIVER=database+DB_TABLE_PREFIX=mdldf_would target Moodle'smdldf_sessionstable — schema mismatch would corrupt it. - SSO logout gap: No mechanism existed to revoke portal access when a Moodle admin suspends or deletes a student — tokens remained valid indefinitely.
- This is the coexistence contract: our portal respects Moodle's authority over user accounts.
Where
app/Shared/Middleware/—ValidateMoodleUserStatusapp/Shared/Models/—MoodleSession,MoodleUser(casts)bootstrap/app.php—moodle.activealias registration.env/.env.example—SESSION_DRIVER=arraytests/Unit/Shared/Middleware/andtests/Unit/Shared/Models/
How
Cache::remember("moodle_user_status:{id}", 60s, ...)fetches an int sentinel:1=active,0=inactive,2=suspended.- Int sentinel avoids PHP strict-type conflicts in closures returning booleans.
- On inactive/suspended:
$user->tokens()->delete()+Cache::forget(key), then JSON 401/403 withAuthErrorCode. - Active users pass straight through — zero overhead beyond a cache lookup.
Who
- Students: Transparently affected — access revoked within ≤60 s of admin action in Moodle.
- Moodle admins: Suspend/delete actions propagate to the portal automatically.
- Our team: Apply
['auth:sanctum', 'moodle.active']stack to all protected route groups.
Sequence Diagram — SSO Logout Flow
sequenceDiagram
participant C as Client (Bearer token)
participant S as auth:sanctum
participant M as moodle.active middleware
participant Ca as Cache (Redis/Array)
participant DB as mdldf_user
participant T as personal_access_tokens
participant H as Route Handler
C->>+S: GET /api/v1/... Authorization: Bearer {token}
S->>S: Resolve token → MoodleUser
S->>+M: next(request) with $user
M->>+Ca: Cache::remember("moodle_user_status:{id}", 60s)
alt Cache HIT
Ca-->>M: int status (0 / 1 / 2)
else Cache MISS
Ca->>+DB: SELECT deleted, suspended, confirmed WHERE id=?
DB-->>-Ca: user row
Ca->>Ca: compute int status
Ca-->>M: int status stored for 60s
end
deactivate Ca
alt status = ACTIVE (1)
M->>+H: next(request)
H-->>-M: 200 OK {data}
M-->>C: 200 OK
else status = INACTIVE (0) — deleted or unconfirmed
M->>T: DELETE WHERE tokenable_id=user.id
M->>Ca: Cache::forget("moodle_user_status:{id}")
M-->>-C: 401 {success:false, code:1000}
else status = SUSPENDED (2)
M->>T: DELETE WHERE tokenable_id=user.id
M->>Ca: Cache::forget("moodle_user_status:{id}")
M-->>C: 403 {success:false, code:1002}
end
deactivate S
Flowchart — Middleware Decision Tree
flowchart TD
A([Incoming Request]) --> B{auth:sanctum resolves user?}
B -- No --> B1([401 Unauthenticated])
B -- Yes --> C{instanceof MoodleUser?}
C -- No --> D([Pass through])
C -- Yes --> E[Cache::remember moodle_user_status:userid TTL 60s]
E --> F{Cache HIT?}
F -- Yes --> G{status int}
F -- No --> H[(SELECT deleted, suspended, confirmed FROM mdldf_user)]
H --> I{deleted=1 or confirmed=0?}
I -- Yes --> J[status = 0 INACTIVE]
I -- No --> K{suspended=1?}
K -- Yes --> L[status = 2 SUSPENDED]
K -- No --> M[status = 1 ACTIVE]
J --> G
L --> G
M --> G
G -- 1 ACTIVE --> N([Pass to Route Handler 200 OK])
G -- 0 INACTIVE --> O[Revoke all tokens + forget cache]
G -- 2 SUSPENDED --> P[Revoke all tokens + forget cache]
O --> Q([401 code 1001 Account not active])
P --> R([403 code 1002 Account suspended])
style B1 fill:#7f1d1d,color:#fca5a5,stroke:#ef4444
style Q fill:#7f1d1d,color:#fca5a5,stroke:#ef4444
style R fill:#78350f,color:#fde68a,stroke:#f59e0b
style N fill:#064e3b,color:#6ee7b7,stroke:#10b981
style D fill:#1e3a5f,color:#93c5fd,stroke:#3b82f6
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Shared/Middleware/ValidateMoodleUserStatus.php | Middleware | Core SSO logout middleware — checks mdldf_user status via cache, revokes tokens on inactive/suspended, returns unified JSON error envelope. |
| app/Shared/Models/MoodleSession.php | Model | New read-only Eloquent model for mdldf_sessions — extends MoodleModel, timestamps disabled, write-protected. Shared infrastructure for future SSO session lookups. |
| app/Shared/Models/MoodleUser.php | Model | Added $casts for deleted, suspended, confirmed → 'boolean' so PHP strict types work correctly in the middleware closure. |
| bootstrap/app.php | Bootstrap | Registered moodle.active middleware alias pointing to ValidateMoodleUserStatus::class. |
| .env / .env.example | Config / Env | Changed SESSION_DRIVER from database to array — prevents accidental writes to mdldf_sessions table via Laravel's session system. |
| tests/Unit/Shared/Middleware/ValidateMoodleUserStatusTest.php | Test | 8 unit tests covering: pass-through for unauthenticated/active users, 401 for deleted/unconfirmed, 403 for suspended, token revocation, cache behaviour, cache invalidation on revocation. |
| tests/Unit/Shared/Models/MoodleSessionTest.php | Test | 4 unit tests: correct table name, timestamps disabled, write protection throws LogicException, extends MoodleModel. |
Rules Applied
-
Architecture
Middleware layer placement:
ValidateMoodleUserStatuslives inapp/Shared/Middleware/as a cross-cutting concern per the Shared module pattern — not inside any feature module. -
Architecture
Moodle read-only models:
MoodleSessionextendsMoodleModelwhich overridessave()anddelete()to throwLogicException. Consistent with the shared-DB read-only model rule. -
Architecture
No DB table prefix in models:
MoodleSession::$table = 'sessions'andMoodleUser::$table = 'user'— bare table name without prefix.DB_TABLE_PREFIXin.envis applied automatically by Laravel. -
Security
Token revocation on status change: All Sanctum tokens are deleted via
$user->tokens()->delete()the moment a suspended or deleted status is detected — no stale tokens persist beyond the 60-second cache window. - Security 401 vs 403 discrimination: Deleted/unconfirmed accounts return HTTP 401 (unauthenticated — identity no longer valid); suspended accounts return HTTP 403 (forbidden — identity valid, access denied). Matches project HTTP status code rules exactly.
-
Security
Unified error envelope: Responses carry
{ success, message, data, errors, code }— no internal details (stack traces, SQL, file paths) exposed. UsesAuthErrorCodeenum for machine-readable codes. -
Coding Style
finalclasses by default: BothValidateMoodleUserStatusandMoodleSessionare declaredfinal. No class is left open for inheritance without a documented reason. -
Coding Style
Named arguments for clarity: The
errorResponse()call uses named arguments —message:,code:,status:— on a 3-parameter method, per the coding-style rule. -
Coding Style
Enums over magic values:
AuthErrorCode::AccountSuspendedandAuthErrorCode::InvalidCredentialsare PHP 8.3 backed enums. No magic integers or strings in the middleware. Int sentinels use namedprivate const intdeclarations. -
Coding Style
Laravel facades over raw PHP:
Cache::remember/Cache::forgetused instead of any raw cache implementation.response()->json()used instead of manual JSON encoding. - Testing TDD — test-first: Tests were written before implementation (tasks 2.1, 4.1 before 2.2, 4.2 in the task list). 8 middleware tests + 4 model tests drive the entire implementation.
-
Testing
Laravel fakes for external dependencies:
Cache::spy()andCache::shouldReceive(...)used to test cache interactions without a real cache store. No manual cache mocking. -
Testing
RefreshDatabase + real DB for feature-style unit tests:
ValidateMoodleUserStatusTestusesRefreshDatabaseand builds a realusertable in the test DB — no Eloquent model mocking, consistent with the "no mocking what you don't own" rule. -
Testing
AAA pattern and descriptive names: Every test method starts with
test_it_and describes behavior, e.g.test_it_returns_403_when_user_is_suspended. Each test has a clear Arrange / Act / Assert structure.