# Deployment hardening

Deploys run `git fetch` + `git reset --hard` inside `/var/www/html/<app>` (see
`.github/workflows/ci-cd.yml`). That means the **`.git/` directory, `tests/`,
`postman/`, `docs/` and every source file physically live in the deployed app
directory** on each server. The only thing keeping them off the web is the vhost
document root.

## 1. Document root must end in `/public`

Apache:

```apache
DocumentRoot /var/www/html/api_<client>_backend/public
<Directory /var/www/html/api_<client>_backend/public>
    AllowOverride All        # required for public/.htaccess to take effect
    Require all granted
</Directory>
```

Nginx:

```nginx
root /var/www/html/api_<client>_backend/public;
include /var/www/html/api_<client>_backend/deployment/nginx-hardening.conf;
```

A safety-net `.htaccess` at the project root denies everything, so a
misconfigured Apache docroot fails closed (403) instead of leaking source.

## 2. Verify after every deploy

From an external host, all of these must return **403 or 404**:

```bash
BASE=https://api.<client>.com
for p in /.git/config /.git/HEAD /.env /.env.example /composer.json /composer.lock \
         /artisan /storage/logs/laravel.log /postman /docs /tests; do
  printf '%s -> ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "$BASE$p"
done
```

If `/.git/config` returns 200, the document root is wrong — fix it immediately
and rotate any secret that was in `.env` / git history.

## 3. File permissions on the server

```bash
cd /var/www/html/api_<client>_backend
sudo chown -R deploy:www-data .
sudo find . -type d -exec chmod 750 {} \;
sudo find . -type f -exec chmod 640 {} \;
sudo chmod -R 770 storage bootstrap/cache
sudo chmod 600 .env
```

## 4. Keep `.git` out of the web response even if docroot is wrong

Both `public/.htaccess` and `deployment/nginx-hardening.conf` block requests for
`/.` paths. Keep those rules; do not rely on them alone.

## 5. Signed media URLs (AUTHZ-7)

`GET /api/v1/media/proxy` has no authentication — anyone who knows or guesses a
`path`+`disk` can read it (see the security audit, finding AUTHZ-7). It's kept
open by default because `<img>`/`<a>` tags can't send an `Authorization`
header, so the mitigation is a short-lived **signed URL** instead of a login
check.

**Already built, off by default:**
- `Helper::media_url()` / `Helper::folder_download_url()` produce a signed URL
  (`URL::temporarySignedRoute`, 15 min) whenever
  `config('filesystems.media_proxy_signed_urls')` is true.
- `App\Http\Middleware\EnsureMediaProxySigned` (route alias `media.signed`,
  applied to `media.proxy`) rejects an unsigned/tampered request with 403 once
  that flag is on. While it's off, every request — signed or not — passes
  through unchanged, so enabling the flag is the only thing that changes
  behaviour.

**To turn it on**, set `MEDIA_PROXY_SIGNED_URLS=true` — but first fix every
place that builds a `/media/proxy?path=&disk=` URL **by hand** instead of
calling `Helper::media_url()` server-side, because a hand-built URL will never
carry a valid signature and will start 403ing:

- `IMS-Frontend-New/src/components/admin/approval/ApprovalDetailsModal.tsx`
  (two spots)
- `IMS-Frontend-New/src/components/common/crm/communications/EmailSMSIntegration.tsx`
- `IMS-Frontend-New/src/components/common/students/StudentCommunicationHistoryModal.tsx`
- anywhere matching `grep -rn "media/proxy?path=" IMS-Frontend-New/src`

The fix for each is the same shape: the backend endpoint that hands the
frontend a raw `path`/`disk` (bank slip path, attachment path, etc.) should
instead return `Helper::media_url($path, $disk)` as a ready-to-use URL field,
and the frontend component should render that field directly instead of
building the query string itself. Re-run the grep above after each change —
when it returns nothing, flip the flag and confirm images/downloads still load
in a full click-through of the app before shipping it to production.

## 6. Refresh token is now an HttpOnly cookie (AUTHN hardening)

The browser used to keep both the access token **and the 7-day refresh token**
in `localStorage`, so any XSS bug meant a permanent account takeover. Now:

- **Backend** (`AuthController`) sets the refresh token as an `HttpOnly; Secure;
  SameSite=None` cookie scoped to `Path=/api/v1/auth` on `login` / `refresh`,
  and expires it on `logout` / `logout-all`. JavaScript can never read it.
  `POST /auth/refresh` reads the cookie (or, for native/mobile, still the
  `refreshToken` body field) and requires an `X-Requested-With: XMLHttpRequest`
  header on the cookie path as CSRF defence.
- **Frontend** keeps only the short-lived access token, **in memory** — nothing
  auth-related is written to `localStorage` anymore. On page load it silently
  calls `/auth/refresh` (cookie attached) to re-mint an access token.
- `JWT_REFRESH_TTL` cut from 7 days to 48h. Rotation-on-use + activity-based
  silent refresh mean active users never notice.

### Required env (see `.env.example`)

| var | value | why |
|-----|-------|-----|
| `CORS_SUPPORTS_CREDENTIALS` | `true` | browser must send/receive the cookie cross-origin; safe because `CORS_ALLOWED_ORIGINS` is an exact allowlist, never `*` |
| `REFRESH_TOKEN_COOKIE_SAME_SITE` | `none` for split-origin (`app.x.com` + `api.x.com`); `lax` if same-site / reverse-proxied | `none` is the only value a browser sends on a cross-site XHR |
| `REFRESH_TOKEN_COOKIE_SECURE` | `true` (must be `true` whenever SameSite is `none`) | |
| `REFRESH_TOKEN_COOKIE_DOMAIN` | blank (host-only) | only set `.x.com` for a multi-subdomain API |
| `REFRESH_TOKEN_COOKIE_PATH` | `/api/v1/auth` | keeps the cookie off every other endpoint |

### One-time deploy steps

1. Set the env vars above **before** deploying the new frontend, or the first
   post-deploy `/auth/refresh` will fail CORS and users can't log in.
2. This deploy invalidates every existing browser session (old localStorage
   tokens are ignored; no cookie exists yet). Users log in once more — expected.
3. **Clear the `is_logged` flag**, or users whose session just died will hit
   *"already logged in from another device"* and be locked out:
   ```
   php artisan tinker --execute="App\Models\User::query()->update(['is_logged' => 0]);"
   ```
4. Local http dev: Chrome allows `Secure` cookies on `http://localhost`; Safari
   and some Firefox builds do not. If local login won't persist across a reload,
   set `REFRESH_TOKEN_COOKIE_SAME_SITE=lax` + `REFRESH_TOKEN_COOKIE_SECURE=false`
   in your local `.env` (only works if the dev API and dev frontend are
   same-site) or run `next dev --experimental-https`.

### Verify after deploy

```
# 1. login sets the cookie
curl -si -X POST https://api.<host>/api/v1/auth/login \
  -H 'Origin: https://app.<host>' -H 'Content-Type: application/json' \
  -d '{"username":"...","password":"..."}' | grep -i set-cookie
#   -> Set-Cookie: refresh_token=...; path=/api/v1/auth; httponly; secure; samesite=none

# 2. refresh without the CSRF header is rejected
curl -s -X POST https://api.<host>/api/v1/auth/refresh \
  -H 'Origin: https://app.<host>' --cookie 'refresh_token=x' | grep -i 'invalid request'

# 3. browser devtools -> Application -> Local Storage shows NO auth_tokens key
```
