Auth Module — Feature Report
5W Analysis
📋 What
- Single endpoint:
POST /api/v1/auth/login - Accepts
identifier(username or email) +password - Verifies bcrypt password against Moodle's
mdldf_usertable (read-only) - Enforces account status:
deleted=0,suspended=0,confirmed=1 - Issues a Sanctum Bearer token into
mdldf_personal_access_tokens - Token TTL from
mdldf_config.sessiontimeout(default 7200 s) - Dispatches
StudentLoggedInevent → Moodle plugin (async) - Returns unified API envelope via
LoginResource
⏱ When
- Triggered on every student login attempt
- Rate-limited to 5 requests/minute per IP (throttle middleware)
- Token TTL read at login time from
MoodleConfigReader(cached 24 h) - Event forwarding runs async on the queue — does not block the HTTP response
- Request lifecycle:
Route → Middleware(throttle:5,1) → LoginController → LoginRequest → AuthenticationService → MoodleUserRepository → DB → Token → Event → LoginResource
💡 Why
- Students already have accounts in Moodle's
mdldf_usertable — no separate user store needed - The portal is stateless (mobile/SPA client) — Moodle session cookies are not usable
- Sanctum tokens are stored in a prefixed table alongside Moodle's own data, keeping the schema unified under one DB connection
- Event forwarding makes Moodle's audit trail accurate — login events appear as if they originated from Moodle itself
📁 Where
app/Modules/Auth/— all Auth-specific classesapp/Shared/Models/—MoodleModel,MoodleUserapp/Shared/Services/MoodleConfigReader.phpapp/Shared/Http/Resources/ApiResource.phpconfig/auth.php,config/database.phproutes/api.php— loads Auth module routestests/Feature/Auth/andtests/Unit/
⚙ How
- DB Prefix:
DB_TABLE_PREFIX=mdldf_applied globally viaconfig/database.php— all tables (Moodle, Sanctum, ours) share one prefix without manual handling - MoodleUser: Extends read-only
MoodleModel; carriesHasApiTokensso token writes go to the prefixedpersonal_access_tokens, not tomdldf_user - AuthenticationService: Lookup → password check → status flags → TTL from config →
createToken()with expiry → dispatch event → return DTO - Error codes: Int-backed enum
AuthErrorCode(1001 = invalid, 1002 = suspended) surfaced in the API envelope'scodefield - ApiResource envelope: Abstract
JsonResourcebase — all responses share{success, message, data, errors, code}shape
👤 Who
- Students — submit identifier + password to obtain a Bearer token
- Moodle — owns
mdldf_user; we only read from it. Receives forwarded login events via REST plugin - Our team — built all components in this plan (Phases 0–4)
- Sanctum — manages token lifecycle in
mdldf_personal_access_tokens
Sequence Diagram
Full HTTP request lifecycle — POST /api/v1/auth/login
sequenceDiagram
participant B as Browser
participant MW as Throttle Middleware
participant C as LoginController
participant R as LoginRequest
participant S as AuthenticationService
participant UR as MoodleUserRepository
participant DB as mdldf_user
participant CR as MoodleConfigReader
participant Cache as Cache
participant T as mdldf_personal_access_tokens
participant EV as Event Dispatcher
participant Q as Queue
B->>+MW: POST /api/v1/auth/login
MW-->>MW: Check rate limit (5/min per IP)
MW->>+C: Pass through
C->>+R: validate(identifier, password)
alt Validation fails
R-->>C: 422 — ValidationException
C-->>B: 422 errors map
end
R-->>-C: LoginDTO
C->>+S: login(dto)
S->>+UR: findByIdentifier(identifier)
UR->>+DB: SELECT WHERE username=? OR email=?
DB-->>-UR: row | null
UR-->>-S: MoodleUser | null
alt User not found
S-->>C: throw AuthenticationException
C-->>B: 401 code:1001
end
S->>S: Hash::check(password, user.password)
alt Wrong password
S-->>C: throw AuthenticationException
C-->>B: 401 code:1001
end
S->>S: Check deleted=0 AND confirmed=1
alt Deleted or unconfirmed
S-->>C: throw AuthenticationException
C-->>B: 401 code:1001
end
S->>S: Check suspended=0
alt Suspended
S-->>C: throw AccountSuspendedException
C-->>B: 403 code:1002
end
S->>+CR: get('sessiontimeout', 7200)
CR->>+Cache: remember('moodle_config:sessiontimeout', 86400)
Cache-->>-CR: cached value | null
CR-->>-S: ttl (seconds)
S->>+T: user->createToken('api', ['*'], expiresAt)
T-->>-S: NewAccessToken
S->>+EV: Event::dispatch(StudentLoggedIn)
EV->>+Q: ForwardEventToMoodle (async)
Q-->>-EV: queued
EV-->>-S: dispatched
S-->>-C: LoginResultDTO
C-->>-B: LoginResource → 200 JSON envelope
Flowchart
Main success path, validation branches, and error paths with HTTP status codes
flowchart TD
A([POST /api/v1/auth/login]) --> RL{Rate limit\n5 req/min?}
RL -->|Exceeded| RL_ERR[429 Too Many Requests]
RL -->|OK| VAL{LoginRequest\nvalidation passes?}
VAL -->|No - missing/invalid fields| V422[422 Unprocessable Entity\nerrors map per field]
VAL -->|Yes - LoginDTO built| FIND[MoodleUserRepository\nfindByIdentifier]
FIND --> FOUND{User found\nin mdldf_user?}
FOUND -->|No| AUTH_FAIL[throw AuthenticationException]
FOUND -->|Yes| PWD{Hash::check\npassword matches?}
PWD -->|No| AUTH_FAIL
PWD -->|Yes| STATUS1{deleted=0\nAND confirmed=1?}
STATUS1 -->|No| AUTH_FAIL
AUTH_FAIL --> RESP401[401 Unauthorized\nsuccess:false\ncode:1001]
STATUS1 -->|Yes| STATUS2{suspended=0?}
STATUS2 -->|No| SUSP_FAIL[throw AccountSuspendedException]
SUSP_FAIL --> RESP403[403 Forbidden\nsuccess:false\ncode:1002]
STATUS2 -->|Yes| TTL[MoodleConfigReader\nget sessiontimeout\ncached 24h]
TTL --> TOKEN[user->createToken\nwith expiresAt]
TOKEN --> EVENT[Event::dispatch\nStudentLoggedIn]
EVENT --> QUEUE[ForwardEventToMoodle\non Queue async]
QUEUE --> RESP200[200 OK\nLoginResource envelope\nsuccess:true\ndata: token + student]
style RESP401 fill:#3f1c1c,stroke:#ef4444,color:#fca5a5
style RESP403 fill:#3f2a0c,stroke:#f59e0b,color:#fcd34d
style RESP200 fill:#0f2e1f,stroke:#10b981,color:#6ee7b7
style V422 fill:#1c1c3f,stroke:#818cf8,color:#c7d2fe
style RL_ERR fill:#3f1c1c,stroke:#ef4444,color:#fca5a5
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Modules/Auth/Controllers/LoginController.php | Controller | Single-action controller for POST /api/v1/auth/login. Delegates to AuthenticationService, maps AuthenticationException → 401 and AccountSuspendedException → 403. Max 15 lines. |
| app/Modules/Auth/Requests/LoginRequest.php | FormRequest | Validates identifier (string, max 100) and password (string, max 255). Provides toDTO() to convert validated input into a typed LoginDTO. |
| app/Modules/Auth/Services/AuthenticationService.php | Service | All business logic: user lookup → password check → status checks → token creation with TTL from Moodle config → event dispatch. Returns LoginResultDTO. |
| app/Modules/Auth/Repositories/MoodleUserRepository.php | Repository | Queries mdldf_user by username or email using DB::table('user') (prefix applied automatically). Hydrates a MoodleUser model from raw row data. |
| app/Modules/Auth/Repositories/MoodleUserRepositoryInterface.php | Interface | Contract for user lookup by identifier. Bound in AppServiceProvider to enable dependency injection and testability. |
| app/Modules/Auth/Resources/LoginResource.php | Resource | Extends ApiResource. Formats the success response: Bearer token, token_type, expires_at (ISO 8601), and student profile fields. |
| app/Modules/Auth/Events/StudentLoggedIn.php | Event | Extends BaseEvent. Maps to Moodle's \core\event\user_loggedin. Context level: User. Forwarded async to the Moodle plugin via ForwardEventToMoodle. |
| app/Modules/Auth/Enums/AuthErrorCode.php | Enum | Int-backed enum scoped to the Auth module: InvalidCredentials = 1001, AccountSuspended = 1002. Surfaced in the API envelope's code field. |
| app/Modules/Auth/DTOs/LoginDTO.php | DTO | final readonly DTO carrying identifier and password from the request layer to the service layer. |
| app/Modules/Auth/DTOs/LoginResultDTO.php | DTO | final readonly DTO carrying the issued NewAccessToken and the authenticated MoodleUser back to the controller. |
| app/Modules/Auth/Exceptions/AuthenticationException.php | Exception | Thrown for wrong credentials, deleted account, or unconfirmed account. Carries AuthErrorCode::InvalidCredentials (1001). Maps to HTTP 401. |
| app/Modules/Auth/Exceptions/AccountSuspendedException.php | Exception | Thrown when credentials are valid but account is suspended. Carries AuthErrorCode::AccountSuspended (1002). Maps to HTTP 403. |
| app/Modules/Auth/routes.php | Route | Defines POST v1/auth/login under throttle:5,1 middleware. Route name: api.v1.auth.login. |
| app/Shared/Models/MoodleModel.php | Model | Abstract Eloquent base for all Moodle tables. Disables timestamps. Overrides save() and delete() to throw LogicException when $readOnly = true. |
| app/Shared/Models/MoodleUser.php | Model | Extends MoodleModel. Uses HasApiTokens (Sanctum). Table: user (prefixed to mdldf_user). Hides password and secret. Read-only. |
| app/Shared/Services/MoodleConfigReader.php | Service | Reads key/value pairs from mdldf_config. Results cached for 24 h via Cache::remember. Used to resolve token TTL from sessiontimeout. |
| app/Shared/Http/Resources/ApiResource.php | Resource | Abstract JsonResource base. Provides unified envelope: {success, message, data, errors, code}. Static error() helper builds error responses. |
| app/Providers/AppServiceProvider.php | Provider | Binds MoodleConfigReaderInterface → MoodleConfigReader (singleton), MoodleUserRepositoryInterface → MoodleUserRepository, and AuthenticationService with constructor injection. |
| config/auth.php | Config | Added api guard using Sanctum driver with moodle_users provider. Added moodle_users provider pointing to MoodleUser::class. |
| config/database.php | Config | Added 'prefix' => env('DB_TABLE_PREFIX', '') to the MySQL connection. Enables uniform table prefixing for Moodle tables, Sanctum tokens, and our own tables. |
| routes/api.php | Route | Loads Auth module routes file via Route::middleware('api')->group(base_path('app/Modules/Auth/routes.php')). |
| tests/Feature/Auth/LoginTest.php | Feature Test | 14+ tests covering full HTTP cycle: success paths (username, email), 401/403/422 error cases, event dispatch assertion, token storage, envelope shape, and token expiry. |
| tests/Unit/Modules/Auth/Services/AuthenticationServiceTest.php | Unit Test | Unit tests for all failure paths in AuthenticationService: user not found, wrong password, deleted, unconfirmed, suspended. Uses Mockery for repository and config reader. |
| tests/Unit/Shared/Models/MoodleModelTest.php | Unit Test | Verifies read-only guard: save() throws on read-only models, delete() throws, and save() succeeds when $readOnly = false. |
| tests/Unit/Shared/Models/MoodleUserTest.php | Unit Test | Verifies MoodleUser: correct table name, timestamps disabled, password hidden, read-only guard, extends MoodleModel, uses HasApiTokens. |
| tests/Unit/Shared/Services/MoodleConfigReaderTest.php | Unit Test | Verifies config lookup by key, default fallback when key not found, and caching: DB is only queried once for the same key across multiple calls. |
Rules Applied
Architecture
Strict layer separation:
LoginController is ≤15 lines, contains zero business logic — delegates entirely to AuthenticationService.
Module self-contained: all Auth classes (controller, service, repository, events, DTOs, exceptions, requests, resources, routes) live in
app/Modules/Auth/.
Shared cross-cutting concerns in
app/Shared/: MoodleModel, MoodleUser, ApiResource, MoodleConfigReader.
Constructor injection everywhere —
AuthenticationService receives MoodleUserRepositoryInterface and MoodleConfigReaderInterface via constructor, not app().
Interfaces bound in
AppServiceProvider::register() — services depend on contracts.
Read-only Moodle models:
MoodleModel guards save() and delete() with LogicException. MoodleUser sets $readOnly = true.
Unified DB prefix via
DB_TABLE_PREFIX env var in config/database.php — no manual table prefix handling anywhere in code.
API response envelope: all responses use
{success, message, data, errors, code} shape defined in ApiResource.
Event forwarding runs async on queue —
ForwardEventToMoodle listener does not block HTTP response.
API versioning: endpoint under
/api/v1/auth/. Route name follows dot.notation: api.v1.auth.login.
DTOs are
final readonly with typed constructor-promoted properties. No business logic in DTOs.
HTTP status codes: 200 success, 401 invalid credentials, 403 suspended, 422 validation failure, 429 rate limit.
Coding Style
PHP 8.3 features used:
readonly properties (DTOs, exceptions), enum (AuthErrorCode), constructor promotion (DTOs, services), typed properties and return types everywhere.
final on all concrete classes: LoginController, AuthenticationService, MoodleUserRepository, LoginDTO, MoodleConfigReader, MoodleUser, etc.
Laravel-first:
Hash:: facade for password check, Cache::remember() for config caching, Event::dispatch() for event dispatching, now()->addSeconds() (Carbon) for token expiry.
No magic strings: error codes in
AuthErrorCode enum, config key 'sessiontimeout' is a named string constant in service code.
PHPDoc on every class and public method: class docblock (one sentence),
@throws on all methods that raise exceptions, @param/@return only where type alone is insufficient.
Custom exception classes per domain:
AuthenticationException and AccountSuspendedException — no raw \Exception thrown or caught.
Strict types:
declare(strict_types=1) in every file. Strict comparison (===) used. Explicit (int) casts on DB column values.
Naming conventions followed: PascalCase classes, camelCase methods,
snake_case DB columns, kebab-case route URIs, dot.notation route names.
Security
Rate limiting:
throttle:5,1 on the login route — 5 attempts per minute per IP, as required for auth endpoints.
Password never logged or exposed:
MoodleUser hides password and secret via $hidden. Service logic uses Hash::check() only.
Stateless API tokens: Sanctum Bearer tokens with TTL — no session cookies, no long-lived credentials.
Input validation in
FormRequest: all input validated before reaching controller or service. No inline validation.
No SQL injection: all queries use Eloquent/Query Builder with parameter binding (
DB::table('user')->where()).
No writes to Moodle tables:
MoodleUser is read-only. Token writes go to mdldf_personal_access_tokens, not mdldf_user.
User enumeration prevention: same
AuthenticationException thrown for "user not found" and "wrong password" — identical 401 response, same error code 1001.
Env config for secrets: Moodle API key and URL read via
config('services.moodle.*') — never hardcoded or read directly from env() in business logic.
Testing
TDD workflow: tests written first (red), minimum implementation to pass (green), refactor. All test files exist for every implemented class.
AAA pattern: every test method follows Arrange → Act → Assert with clear section separation.
Test naming:
snake_case with test_it_ prefix, describes behavior and expected outcome (e.g., test_it_returns_403_when_user_is_suspended).
Feature tests use
RefreshDatabase trait and hit the real (in-memory SQLite) database — no mocking of Eloquent or DB layer.
Laravel fakes used correctly:
Event::fake() for event dispatch assertion, Queue::fake() to prevent async listener execution, Http::fake() to intercept Moodle API calls.
Moodle tables created in test setUp via
Schema::create() — no factories or migrations for Moodle-owned tables; raw DB::table()->insert() for test data.
Specific assertions:
assertJsonPath() over generic assertOk(), exact status codes checked, JSON structure validated with assertJsonStructure().
100% coverage of event dispatching:
test_it_dispatches_student_logged_in_event_on_success uses Event::assertDispatched(StudentLoggedIn::class).
Unit tests use Mockery to mock interfaces (
MoodleUserRepositoryInterface, MoodleConfigReaderInterface) — not concrete classes.
Test independence: each test sets up its own data in
setUp() and relies on RefreshDatabase for teardown — no shared mutable state.