# Feature 03 — Captain Authentication

- **Status:** Implemented
- **Date:** 2026-09-10
- **Mechanism:** Reused the existing captain sign-in built on **WhatsApp OTP + Laravel Sanctum tokens**. No second authentication mechanism was introduced.
- **Phone + password:** The existing project has no password for captains (`drivers` has no password column; only `admins` authenticate with a password). Per the Feature 03 instruction *"phone/password authentication if supported by the existing project"*, password login was **not** added — OTP is the single captain authentication mechanism.

---

## 1. How the captain authenticates

1. A captain **registers** (`POST /api/driver/auth/register`, stays `pending`).
2. An admin reviews the application and decides (`approve` / `reject` / `request-documents`).
3. **Only an `approved` and `active` captain** can request a code. The check is done server-side on every sign-in attempt; the app never sends a status.
4. `POST /api/driver/auth/login` sends a 6-digit code by WhatsApp to the registered phone.
5. `POST /api/driver/auth/verify` exchanges a valid code for a Sanctum bearer token.
6. The token authenticates every protected captain route; `POST /api/driver/auth/logout` revokes it.

Routes (`routes/driver.php`):

| Method | Route | Middleware | Purpose |
|---|---|---|---|
| POST | `/api/driver/auth/register` | `throttle:api` | Submit application (stays `pending`) |
| POST | `/api/driver/auth/login` | `throttle:otp` | Send the OTP to the phone |
| POST | `/api/driver/auth/resend` | `throttle:otp` | Send a fresh OTP (invalidates the old one) |
| POST | `/api/driver/auth/verify` | `throttle:otp` | Exchange a valid code for a Sanctum token |
| POST | `/api/driver/auth/logout` | `auth:driver`, `throttle:api` | Revoke the current captain's tokens |

Every API route also carries the global `throttle:api` (`RateLimiterProvider`), `CheckApiHeaderMiddleware`, and the two required headers `Accept: application/json` and `Accept-Language: en|ar`.

---

## 2. Requests and validation

All phone numbers are normalized server-side (`NormalizesPhoneNumber`) into `+<digits>` before any lookup or rule runs — the app may send `9665...`, `00966 5...`, or `00...`; the stored shape is `+966...`.

**`POST login` / `POST resend` — `DriverPhoneRequest`**
```json
{ "phone": "+966500000010" }
```
- `phone`: required, `+` followed by 8–15 digits.

**`POST verify` — `VerifyDriverOtpRequest`**
```json
{ "phone": "+966500000010", "code": "482913", "device_name": "iphone-15" }
```
- `phone`: as above.
- `code`: required, `digits:` the configured `otp.length` (default 6). A wrong format is a 422 before the code is checked.
- `device_name`: optional string; becomes the Sanctum token name (default `driver`).

**`POST logout` — `auth:driver`**
No body. Requires `Authorization: Bearer <token>`.

Unexpected fields and malformed JSON are rejected with the project's validation envelope (`MessageDebug.validation`).

---

## 3. Approval-status authorization (server-side)

`DriverAuthService::driverAllowedToSignIn()` runs on **both** `login` and `verify`, before any code is sent or checked:

1. Phone not registered → `not_registered` → **404**.
2. `is_active` is false → `disabled` → **403**.
3. `status->canSignIn()` is true **only** for `approved` → otherwise `pending` → **423** (`pending`, `documents_required`), or `rejected` / `suspended` → **403**.

A status is never read from the request. The reason also rides in `MessageDebug.reason`, so the app can branch on a value rather than a translated message.

Protected routes (`profile`, `device-token`, `availability`, `location`) use the `sanctum` guard (`config/auth.php`: `driver` → `sanctum` → eloquent `Driver`). A valid token persists only while the account stays usable: `toggleActivation` revokes every session when a captain is deactivated (`DriverRepository::revokeTokens`), so `is_active=false` closes already-open sessions, not just future sign-ins. Sanitizing status writes goes through `DriverStateMachine`, and `approved` is a terminal status, so no approved session can be strangled by a later status downgrade either.

---

## 4. Responses and errors

The phone-based sign-in answers **before** any token exists with the same envelope (`ApiResponder`) every endpoint uses:

```
{ "Model": ..., "Status": true|false, "Message": "...", "MessageDebug": ..., "Total": 0, "Page": 0, "Records": 0 }
```

| Outcome | HTTP | `Message` | `MessageDebug.reason` |
|---|---|---|---|
| Code sent / resend | 200 | `We have sent your verification code!` | — |
| Code verified → token | 200 | `Verification code verified!` | — |
| Logged out | 200 | `You have been logged out.` | — |
| Unknown phone | 404 | `not_registered` message | `not_registered` |
| Application under review (`pending`/`documents_required`) | 423 | `pending_review` message | `pending` |
| Rejected application | 403 | `application_rejected` message | `rejected` |
| Deactivated / suspended | 403 | `account_disabled` message | `disabled` |
| Wrong code | 422 | `Mismatched verification code!` | (validation on `code`) |
| Code exhausted (attempt limit) | 422 | `Too many wrong attempts, please request a new code.` | (validation on `code`) |
| Expired code | 422 | `Your verification code has expired...` | (validation on `code`) |
| No pending code | 422 | `No verification code!` | (validation on `code`) |
| Validation failure | 422 | first error | `validation` |
| Throttled (per phone or per IP) | 429 | throttle message | — |
| Missing/invalid headers | 401 | middleware message | — |
| Missing/invalid token on protected route | 401 | `Unauthenticated.` | — |

On `verify`, non-`OtpStatus::Matched` results are returned as `MessageDebug.validation.code`, matching the field the captain typed so the app can flag it inline. If the status check refused first, the code is never looked at (`otp` is null).

---

## 5. Security

- **Password hashing:** captain auth has no password by design (OTP only). Admin passwords use Laravel's default bcrypt.
- **Token security:** Sanctum `plainTextToken`s (36-char random). Logout and deactivation revoke **all** tokens (`tokens()->delete()`).
- **OTP at rest:** only `hash('sha256', $code)` is cached, never the code; comparison uses `hash_equals`. Payloads hold scalars only (safe under serializing cache stores).
- **OTP expiration:** configurable `otp.expires` (default 15 min), enforced by timestamp; expired codes report `expired` (a 30-min grace keeps the record readable to distinguish "expired" from "never sent").
- **OTP single use:** a matched code is consumed; resend replaces the pending code.
- **Attempt limit (new in Feature 03):** `otp.attempts` wrong tries (default 5) invalidate the pending code; the invalidating try answers `exhausted` and the captain must request a new code. `0` disables the limit.
- **Brute-force protection:** layered — 6-digit space (1/1,000,000 per try), the attempt limit above, and `throttle:otp` (4 requests/min per phone **and** 4/min per IP) on `login`/`verify`/`resend`. Rate limits are enforced in `RateLimiterProvider`.
- **Account status:** checked server-side on every sign-in attempt; sessions are revoked on deactivation.
- **Token revocation/logout:** `DriverAuthService::logout()` deletes all tokens; the `auth:driver` guard then rejects the bearer.

---

## 6. Testing

Files:
- `tests/Feature/Driver/DriverAuthApiTest.php` — endpoint behaviour (approved sign-in, pending/rejected/disabled/unknown, wrong/empty/reused code, resend, logout + revocation, protected route after logout, throttling, header middleware, attempt limit, Arabic negotiation).
- `tests/Feature/Driver/OtpServiceTest.php` — code lifecycle: matches once and is consumed, wrong codes don't consume (until the limit), expiry, resend replaces, clear-text never stored, serializing-cache-store safety, attempt-limit invalidation.
- `tests/Feature/Admin/AdminAuthApiTest.php`, `tests/Feature/Admin/AdminManagementApiTest.php` — the shared `OtpService` is exercised through the admin password-reset flow to prove the new `exhausted` outcome is compatible.

Commands:
```powershell
php artisan test tests/Feature/Driver/DriverAuthApiTest.php tests/Feature/Driver/OtpServiceTest.php
php artisan test tests/Feature/Admin/AdminAuthApiTest.php tests/Feature/Admin/AdminManagementApiTest.php
```

### E2E happy path

```
Approved captain → login (OTP over WhatsApp) → verify → token →
Authorization: Bearer <token> on /api/driver/profile → logout → same token rejected
```

Blocked cases verified live: `pending` → 423; `rejected` → 403; unknown number → 404; wrong code ×5 → code invalidated (`exhausted`); wrong code then correct code → `empty`.

## 7. Configuration

`.env` (all optional):

| Key | Default | Meaning |
|---|---|---|
| `OTP_FORMAT` | `numeric` | `numeric` \| `alpha` \| `alphanumeric` |
| `OTP_LENGTH` | `6` | code length |
| `OTP_EXPIRES` | `15` | minutes a code is valid |
| `OTP_ATTEMPTS` | `5` | wrong tries before a code is invalidated (`0` = unlimited) |
| `OTP_STORE_KEY` | `otp` | cache key prefix |