# Story 1.2: User Authentication

## Story

**As a** system administrator,
**I want** to log in with a username and password and have all application routes protected behind authentication,
**So that** the system is only accessible to authorized users.

## Status

done

## Acceptance Criteria

**AC1:** Given the application is running, when a user navigates to `/login`, then a login form is displayed with username and password fields and a submit button, and the form includes a hidden CSRF token field validated server-side on submission, and the page uses the established Bootstrap 5 layout (sidebar hidden or minimal on login page is acceptable).

**AC2:** Given valid credentials are submitted to `/login`, when `AuthService` validates the username and password against the `users` table (bcrypt hash comparison), then a server-side session is created storing the user's id and role, and the user is redirected to the main dashboard stub, and the session cookie is `HttpOnly` and `SameSite=Strict`.

**AC3:** Given invalid credentials are submitted to `/login`, when `AuthService` fails to authenticate, then the login page is re-rendered with a human-readable error message: "Invalid username or password.", and no session is created, and the failed attempt is logged to `logs/app.log` with timestamp (username attempted, not the password).

**AC4:** Given an unauthenticated user attempts to access any route other than `/login`, when `AuthMiddleware` inspects the session, then the user is redirected to `/login`, and the originally requested URL is preserved so the user is redirected back after successful login.

**AC5:** Given an authenticated user clicks "Log out", when the logout action is triggered (POST to `/logout`), then the session is destroyed server-side, and the session cookie is cleared, and the user is redirected to `/login`.

**AC6:** Given the `users` table migration is run via Phinx, when the schema is inspected, then the `users` table exists with columns: `id` (serial PK), `username` (varchar, unique, not null), `password_hash` (varchar, not null), `role` (varchar, not null, values: `admin` or `user`), `created_at` (timestamp, default now()), `updated_at` (timestamp, default now()), and an index exists on `username` (`idx_users_username`).

**AC7:** Given the migration has run, when a database seed or manual insert creates a default admin user, then the admin user can log in using the credentials defined in `.env` (e.g., `ADMIN_USERNAME`, `ADMIN_PASSWORD`), and the stored password is a bcrypt hash, never plaintext.

**AC8:** Given a CSRF token mismatch on the login form submission, when `CsrfMiddleware` validates the token, then the request is rejected with a 403 response, and a human-readable error is displayed: "Invalid form submission. Please try again."

## Tasks / Subtasks

- [x] Task 1: Create users table migration
  - [x] 1.1: Create `db/migrations/20260520000001_create_users_table.php` with serial PK, username (unique), password_hash, role, created_at, updated_at, and idx_users_username index
  - [x] 1.2: Run migration and verify schema

- [x] Task 2: Create admin user seeder
  - [x] 2.1: Create `db/seeds/` directory
  - [x] 2.2: Create `db/seeds/AdminUserSeeder.php` reading ADMIN_USERNAME/ADMIN_PASSWORD from .env, inserting with bcrypt hash using upsert
  - [x] 2.3: Run seeder and verify admin user exists with bcrypt hash

- [x] Task 3: Add session management to bootstrap
  - [x] 3.1: Add session configuration and `session_start()` to `src/bootstrap.php` after Config is loaded

- [x] Task 4: Create CsrfMiddleware
  - [x] 4.1: Create `src/Middleware/CsrfMiddleware.php` with `getToken()` (lazy-init per session) and `validate()` using `hash_equals`

- [x] Task 5: Create AuthService
  - [x] 5.1: Create `src/Services/AuthService.php` with `validateCredentials(string $username, string $password): ?array` and `getUserById(int $id): ?array`

- [x] Task 6: Create AuthMiddleware
  - [x] 6.1: Create `src/Middleware/AuthMiddleware.php` with `requireAuth()` (redirects to /login preserving URL) and `getCurrentUser(): ?array`
  - [x] 6.2: Add `requireRole(string $role)` for future admin-only routes

- [x] Task 7: Create AuthController and login view
  - [x] 7.1: Create `src/Controllers/AuthController.php` with `showLogin()`, `processLogin()`, and `logout()`
  - [x] 7.2: Create `src/Views/login.php` — Bootstrap login page (no sidebar), CSRF token hidden field

- [x] Task 8: Update routes and protect dashboard
  - [x] 8.1: Add GET `/login` → AuthController::showLogin() in `public/index.php`
  - [x] 8.2: Add POST `/login` → AuthController::processLogin() in `public/index.php`
  - [x] 8.3: Add POST `/logout` → AuthController::logout() in `public/index.php`
  - [x] 8.4: Wrap the GET `/` handler with `AuthMiddleware::requireAuth()`

- [x] Task 9: Update layout with user info and logout
  - [x] 9.1: Update `src/Views/layout.php` to show current username and logout form button in sidebar footer

- [x] Task 10: Write tests and validate all acceptance criteria
  - [x] 10.1: Create `tests/Unit/AuthServiceTest.php` — test validateCredentials (valid, wrong password, unknown user) and getUserById
  - [x] 10.2: Create `tests/Unit/CsrfMiddlewareTest.php` — test token generation, reuse, validation pass/fail
  - [x] 10.3: Run full test suite and verify no regressions

## Dev Notes

### Architecture Requirements

- **Session:** Start session in `src/bootstrap.php` using config from `config/app.php` session block. Use `session_name()`, `session_set_cookie_params()`, then `session_start()` if session is not already active.
- **CSRF:** Token stored in `$_SESSION['csrf_token']`, generated with `bin2hex(random_bytes(32))`. Validate with `hash_equals()` to prevent timing attacks. Token persists for the session lifetime (not per-request).
- **AuthService:** Depends only on `App\Utils\Database` and `password_verify()`. No framework dependencies.
- **AuthMiddleware:** Reads `$_SESSION['user_id']`. Redirects to `/login?redirect=` with the current URI. After login, `processLogin()` reads `$_GET['redirect']`, validates it starts with `/`, and redirects there.
- **Open-redirect guard:** `$_GET['redirect']` must start with `/` (no `//`, no `http://`). Reject anything else.
- **session_regenerate_id(true):** Call after successful login to prevent session fixation attacks.
- **Logout:** Clear `$_SESSION`, call `session_destroy()`, clear the session cookie via `setcookie()`, redirect to `/login`.
- **Login view:** No sidebar needed — use a centered card layout. Preserve `?redirect=` in form action URL so it survives the POST.
- **Users table:** `id` serial PK (Phinx default), `role` default `'user'`, `created_at`/`updated_at` default `CURRENT_TIMESTAMP`.
- **Seed:** Use upsert (`ON CONFLICT (username) DO UPDATE`) so the seeder is idempotent. Read credentials from `$_ENV` (populated by dotenv in phinx.php). Validate `ADMIN_PASSWORD` is non-empty before hashing.
- **No new Composer packages required:** `password_hash`/`password_verify` are PHP built-ins. Sessions are native PHP.

### Deferred items to carry forward

- W2: Session `secure: false` — needs env-based toggle when deployed to HTTPS. This story keeps `false` per config/app.php; revisit when prod deployment is confirmed.
- W3: No authentication on routes other than `/` is handled here; future stories add routes and must apply `AuthMiddleware::requireAuth()`.
- W6: No CSRF on POST routes other than `/login` and `/logout` yet — each future story that adds forms must call `CsrfMiddleware::validate()`.

## Dev Agent Record

### Debug Log

- `grep -oP '(?<=...)' html` pattern failed in bash subshell because the session cookie was `#HttpOnly_` prefixed; fixed by using `\K` Perl regex syntax to extract the CSRF token for smoke testing.

### Completion Notes

All 8 acceptance criteria satisfied and verified:

- **AC1:** GET `/login` returns 200 with Bootstrap card layout, username/password fields, submit button, and hidden `_csrf_token` field. No sidebar.
- **AC2:** POST `/login` with valid credentials creates session (`user_id`, `user_name`, `user_role`), calls `session_regenerate_id(true)`, and redirects to `/` (or preserved `?redirect=` path). Session cookie is `HttpOnly` + `SameSite=Strict`.
- **AC3:** Invalid credentials → 200 re-renders login with "Invalid username or password." message. Failed attempt logged to `logs/app.log` with username (no password). No session created.
- **AC4:** Unauthenticated GET to `/` → 302 redirect to `/login?redirect=%2F`.
- **AC5:** POST `/logout` with valid CSRF → session destroyed, cookie cleared, 302 redirect to `/login`.
- **AC6:** `users` table has id (serial PK), username (varchar unique), password_hash, role (default 'user'), created_at (CURRENT_TIMESTAMP), updated_at (CURRENT_TIMESTAMP), plus `idx_users_username` unique index.
- **AC7:** AdminUserSeeder inserts admin user from `.env` ADMIN_USERNAME/ADMIN_PASSWORD with bcrypt hash (`$2y$10$...`). Upsert is idempotent.
- **AC8:** CSRF token mismatch → 403 with "Invalid form submission. Please try again." rendered in login page.

Tests: 22 tests, 41 assertions — all passing (13 new tests added across AuthServiceTest and CsrfMiddlewareTest; 9 pre-existing tests unchanged).

### File List

```
db/migrations/20260520000001_create_users_table.php
db/seeds/AdminUserSeeder.php
src/bootstrap.php                     (modified — session start added)
src/Middleware/CsrfMiddleware.php
src/Middleware/AuthMiddleware.php
src/Services/AuthService.php
src/Controllers/AuthController.php
src/Views/login.php
src/Views/layout.php                  (modified — logout form + sidebar footer)
public/index.php                      (modified — login/logout routes, auth guard on /)
public/assets/css/styles.css          (modified — sidebar-footer styles)
tests/Unit/AuthServiceTest.php
tests/Unit/CsrfMiddlewareTest.php
```

### Review Findings

- [x] [Review][Patch] Open redirect: regex allows `//evil` path [src/Controllers/AuthController.php:59] — fixed via `str_starts_with($redirect, '//')` guard
- [x] [Review][Patch] CSRF token not rotated after session_regenerate_id on login [src/Controllers/AuthController.php:53] — fixed via `unset($_SESSION['csrf_token'])` after regenerate
- [x] [Review][Patch] Logout CSRF failure renders login page while user is still authenticated [src/Controllers/AuthController.php:69-75] — fixed; now redirects to `/` with 403
- [x] [Review][Patch] No username length guard — oversized input causes unhandled PDOException [src/Services/AuthService.php:12] — fixed via early `return null` when username empty or > 255 chars
- [x] [Review][Defer] No rate limiting or lockout on login endpoint — deferred, out of Story 1.2 scope
- [x] [Review][Defer] `password_verify` bcrypt 72-byte truncation (known PHP/bcrypt limit) — deferred, pre-existing language limitation
- [x] [Review][Defer] `getUserById` not called for per-request session re-validation — deferred, acceptable for session-based auth at this stage
- [x] [Review][Defer] `updated_at` has no DB-level auto-update trigger — deferred, pre-existing; requires PostgreSQL trigger or app-level handling
- [x] [Review][Defer] `declare(strict_types=1)` inconsistency across files — deferred, pre-existing; stylistic
- [x] [Review][Defer] `session_destroy()` without `session_write_close()` under concurrent requests — deferred, pre-existing (covered by W4 PHP-FPM assumption)

## Change Log

| Date | Change |
|------|--------|
| 2026-05-20 | Story created; status set to in-progress |
| 2026-05-20 | All tasks complete; status set to review |
| 2026-05-20 | Code review complete; 4 patch findings, 6 deferred; status set to in-progress |
| 2026-05-20 | All 4 patches applied; 22/22 tests passing; status set to done |
