# Auth Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Auth module handles **authentication and session management** for the ERP system:
- Employee login/logout (username + password)
- Session creation, tracking, and concurrent session enforcement
- Account lockout after failed attempts
- Password change with history enforcement
- Force password change on first login
- Login attempt auditing (success/failure with reasons)
- API token management for external integrations (REST API v1)
- Home page routing based on employee permissions

It does **NOT** manage:
- Employee CRUD (Users module)
- Roles or permissions assignment (Roles/Users modules)
- Authorization checks in controllers (framework-level PermissionMiddleware)

---

## 2. Directory & File Structure

```
app/Modules/Auth/
├── bootstrap.php                          # Permission registration (auth.force_logout)
├── Routes.php                             # Web routes (6 routes)
├── ApiRoutes.php                          # REST API v1 token endpoints (3 routes)
├── Controllers/
│   ├── AuthController.php                # Login, logout, password change, home redirect
│   └── Api/
│       └── TokenController.php           # API token CRUD (create, list, revoke)
├── Models/
│   ├── ActiveSession.php                 # Track active user sessions
│   └── LoginAttempt.php                  # Record login attempts with metadata
└── Views/
    ├── login.php                         # Login form page
    └── change-password.php              # Password change form
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `employees` Table (6 rows — shared with Users module, auth-relevant columns)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| username | varchar(50) | NO | UNI | Login identifier |
| password_hash | varchar(255) | NO | | bcrypt hash |
| is_active | tinyint(1) | NO | MUL | Must be 1 to login |
| force_password_change | tinyint(1) | NO | | Default: 0, set on creation |
| password_changed_at | timestamp | YES | | Last password change |
| last_login_at | timestamp | YES | | Updated on successful auth |
| last_login_ip | varchar(45) | YES | | IPv4/IPv6 |
| failed_login_count | int unsigned | NO | | Default: 0, reset on success |
| locked_until | timestamp | YES | | Set after max_failed_attempts |
| is_archived | tinyint(1) | NO | MUL | Archived accounts cannot login |

### 3.2 `login_attempts` Table

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| username | varchar(50) | NO | MUL | Attempted username |
| ip_address | varchar(45) | NO | MUL | Client IP |
| user_agent | varchar(500) | YES | | Browser/client info |
| success | tinyint(1) | NO | | 0=failed, 1=success |
| failure_reason | varchar(200) | YES | | user_not_found/archived/inactive/locked/wrong_password |
| attempted_at | timestamp | NO | MUL | When attempt occurred |

### 3.3 `active_sessions` Table

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| employee_id | bigint unsigned | NO | MUL | FK to employees |
| session_id | varchar(128) | NO | MUL | PHP session ID |
| ip_address | varchar(45) | NO | | Client IP |
| user_agent | varchar(500) | YES | | Browser/client info |
| logged_in_at | timestamp | NO | | Session start time |
| last_activity_at | timestamp | NO | | Updated on activity |
| is_active | tinyint(1) | NO | MUL | 0=ended, 1=live |

### 3.4 `password_history` Table

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| employee_id | bigint unsigned | NO | MUL | FK to employees |
| password_hash | varchar(255) | NO | | Previous bcrypt hash |
| created_at | timestamp | NO | | When password was set |

### 3.5 `api_tokens` Table

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | int unsigned | NO | PRI | auto_increment |
| employee_id | int unsigned | NO | MUL | FK to employees |
| token | varchar(64) | NO | UNI | 32-byte hex token |
| name | varchar(100) | NO | | Token name (default: 'default') |
| abilities | json | YES | | Reserved for future scoping |
| last_used_at | datetime | YES | | Updated on API request |
| expires_at | datetime | YES | MUL | TTL-based expiration |
| is_revoked | tinyint(1) | NO | | 0=active, 1=revoked |
| created_at | datetime | NO | | |

---

## 4. Authentication Flow

```
1. GET /login → Show login form
2. POST /login (username, password)
   ├── Validate non-empty
   ├── Find employee by username (Employee::findByUsername — includes archived)
   ├── Check: exists? → fail "user_not_found"
   ├── Check: is_archived? → fail "archived"
   ├── Check: is_active? → fail "inactive"
   ├── Check: isLocked()? → fail "locked"
   ├── Check: password_verify() → fail "wrong_password", increment failed count
   ├── SUCCESS:
   │   ├── resetFailedLogins()
   │   ├── recordLogin(ip)
   │   ├── session->regenerate() (CSRF protection)
   │   ├── Store employee_id, employee_name, logged_in_at, last_activity_at
   │   ├── LoginAttempt::record(success)
   │   ├── ActiveSession::createSession()
   │   ├── enforceMaxSessions() — deactivate oldest if > max
   │   ├── EventBus::dispatch('auth.login')
   │   ├── If force_password_change → redirect /change-password
   │   └── Else → resolveHomePage() (first visible menu item)
```

### Account Lockout
- After `auth.max_failed_attempts` (default: 5) failures → `locked_until` = now + `auth.lockout_minutes` (default: 15)
- Lockout check: `locked_until > time()`
- Reset on successful login

### Max Concurrent Sessions
- Config: `auth.max_concurrent_sessions` (default: 3)
- On login, deactivate oldest sessions exceeding max

---

## 5. Password Change Flow

```
POST /change-password
├── Verify current password (skipped if force_password_change)
├── Validate min length (config auth.password_min_length, default: 8)
├── Validate confirmation match
├── Validate complexity: uppercase + lowercase + digit required
├── Check password history (last N passwords, config auth.password_history_count: 5)
├── Hash with bcrypt (cost from config auth.password_bcrypt_cost: 12)
├── Update employees: password_hash, force_password_change=0, password_changed_at
├── Insert into password_history
├── EventBus::dispatch('auth.password_changed')
└── Redirect to home page
```

---

## 6. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `auth.login` | `{employee_id, ip}` | AuthController::authenticate |
| `auth.logout` | `{employee_id}` | AuthController::logout |
| `auth.password_changed` | `{employee_id}` | AuthController::updatePassword |

---

## 7. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `auth.login` | Audit | Log login event to audit trail |
| `auth.logout` | Audit | Log logout event to audit trail |
| `auth.password_changed` | Audit | Log password change to audit trail |

---

## 8. Cross-Module Dependencies

### 8.1 Auth module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Users | Employee model (findByUsername, password verification, login recording) |
| Core | MenuRegistry (resolve home page based on permissions) |

### 8.2 Other modules that IMPORT FROM Auth:

| Module | What it imports |
|--------|----------------|
| Users | ActiveSession::getActiveForEmployee() in UserController::show() |

---

## 9. Routes

### Web Routes (6)

| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | / | AuthController@home | auth | null |
| GET | /login | AuthController@login | — | null |
| POST | /login | AuthController@authenticate | csrf | null |
| GET | /logout | AuthController@logout | — | null |
| GET | /change-password | AuthController@changePassword | auth, csrf | null |
| POST | /change-password | AuthController@updatePassword | auth, csrf | null |

### API Routes (3)

| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| POST | /api/v1/auth/token | TokenController@store | cors | null |
| GET | /api/v1/auth/tokens | TokenController@index | cors, api_auth | null |
| POST | /api/v1/auth/tokens/{id}/revoke | TokenController@revoke | cors, api_auth | null |

---

## 10. Permissions

| Key | Description |
|-----|-------------|
| auth.force_logout | Force-logout another user's session |

Note: Login/logout/password-change routes have NO permission requirement (they check auth middleware only, or are public).

---

## 11. Configuration Dependencies

| Config Key | Default | Purpose |
|------------|---------|---------|
| auth.password_min_length | 8 | Minimum password length |
| auth.password_bcrypt_cost | 12 | bcrypt hashing cost |
| auth.password_history_count | 5 | Number of previous passwords to check |
| auth.max_failed_attempts | 5 | Attempts before lockout |
| auth.lockout_minutes | 15 | Lockout duration |
| auth.max_concurrent_sessions | 3 | Max active sessions per employee |
| auth.default_redirect | /users | Fallback redirect after login |
| api.token.max_per_employee | 5 | Max active API tokens per employee |
| api.token.ttl_hours | 720 | API token expiry (30 days) |

---

## 12. High-Risk Areas

### 12.1 Session Regeneration
- `session->regenerate()` is called on login to prevent session fixation
- If this fails silently, old session IDs remain valid

### 12.2 Account Lockout Logic
- Lockout is per-employee (not per-IP), so a targeted brute-force against one username is mitigated
- But distributed attacks across usernames are not rate-limited at IP level

### 12.3 Password History Check
- Uses `password_verify()` against previous hashes — computationally expensive with high bcrypt cost
- Could be slow if history is large, but limited by `password_history_count`

### 12.4 API Token Security
- Tokens are stored as plaintext hex in the database (not hashed)
- Token revocation is soft (is_revoked flag), not deletion
- No IP restriction on API token usage

### 12.5 Home Page Resolution
- `resolveHomePage()` iterates all visible menu items — if no menu items are visible, falls back to `/users`
- Could redirect to a page the user cannot access if MenuRegistry is misconfigured

---

## 13. Known Patterns & Gotchas

1. **No registration flow**: Employees are created by admins in Users module, not self-registration
2. **force_password_change**: Set to 1 on employee creation and password reset by admin
3. **Session storage**: PHP native sessions (file-based or configured handler)
4. **Login form is the only public page**: All other routes require `auth` middleware
5. **`findByUsername` uses `withArchived()`**: Archived employees are found (to show proper error) but blocked from login
6. **Max sessions enforcement**: Oldest sessions are deactivated, but their PHP session files persist until GC
