diff --git a/.example.env b/.example.env index dbdd07c7..fd1feb57 100644 --- a/.example.env +++ b/.example.env @@ -2,11 +2,11 @@ DATABASE_HOST=localhost DATABASE_PORT=5432 -DATABASE_NAME=codebloom +DATABASE_NAME=patchats DATABASE_USER=postgres DATABASE_PASSWORD=enterpasswordhere # With the example values, this gets combined inside of the application.properties to make -# jdbc://postgresql://localhost:5432/codebloom?user=postgres&password=enterpasswordhere +# jdbc://postgresql://localhost:5432/patchats?user=postgres&password=enterpasswordhere # SMTP — consumed by spring.mail.* in non-dev profiles (the dev profile logs instead of sending) SMTP_HOST=smtp.example.com @@ -16,3 +16,8 @@ SMTP_PASSWORD=enterpasswordhere # The verified From sender (a real, monitored mailbox on a domain you control) EMAIL_FROM=coffeechats@patinanetwork.org EMAIL_FROM_NAME=PatChats + +# Auth — public origin of the SPA; magic links point at $APP_BASE_URL/auth/verify?token=... +APP_BASE_URL=http://localhost:5173 +# Set to false only when serving over plain HTTP (the dev profile already does this) +AUTH_COOKIE_SECURE=true diff --git a/docs/auth-feature.md b/docs/auth-feature.md index 5d664e2e..d28b3220 100644 --- a/docs/auth-feature.md +++ b/docs/auth-feature.md @@ -5,11 +5,9 @@ email, receives a single-use link, and clicking it establishes a server-side ses httpOnly cookie. **Form-first membership.** The sign-up form is the only way a member row is created; magic links -purely sign in **existing** members. Requesting a link never reveals whether an account exists — the -response is always the same generic 200, but for unregistered emails the backend silently sends -nothing (logged at info level). Wiring the sign-up form submission to a real create-member endpoint -is a separate ticket; until it lands, a login-capable member can only be created with a manual DB -insert (see the walkthrough below). +purely sign in **existing** members. Requesting a link for an email with no member row therefore +**fails with 404**, and the login page turns that into a dead-end panel offering the two ways +forward: try another address, or go sign up. ## The shape @@ -22,8 +20,7 @@ src/main/java/org/patinanetwork/patchats/auth/ RequestLinkRateLimiter.java Bucket4j: 3/email + 10/IP per 15 min, in-memory buckets AuthProperties.java @ConfigurationProperties("app.auth") → base-url, cookie-secure, magic-link-ttl repo/ - MagicLinkTokenRepository.java JdbcClient; atomic UPDATE..RETURNING consume - MemberAccountRepository.java auth's read-only view of members (findByEmail, findById) + MagicLinkTokenRepo.java JdbcClient; atomic UPDATE..RETURNING consume security/ SecurityConfig.java filter chains, cookie serializer, CSRF rationale (read its javadoc) AuthenticatedMember.java Serializable session principal (memberId + email) @@ -40,12 +37,15 @@ js/src/features/auth/ 1. `POST /api/auth/request-link {email}` — normalizes the email, then rate-limits **visibly**: an exhausted budget (3/email + 10/IP per 15 min) returns HTTP 429 with a friendly message, for **all** emails alike — the limiter runs before the member-existence check, so the 429 is - registration-blind and legitimate users know to stop retrying. Unregistered emails are skipped - *silently* (same generic 200 as a real send); that silence is the enumeration guard. For a - registered member it deletes outstanding tokens for that email, stores a **SHA-256 digest** of a + registration-blind and legitimate users know to stop retrying. Keep that ordering: it is also + what throttles probing. An email with no member row + then fails with HTTP 404 (`UnregisteredEmailException`). For a + registered member it stores a **SHA-256 digest** of a fresh 256-bit token (raw is never persisted), and emails `/auth/verify?token=`. Links expire after 15 minutes - (`app.auth.magic-link-ttl`). + (`app.auth.magic-link-ttl`). Issuing a link **does not** invalidate earlier ones — a member who + asks for a second link and then clicks the first email still gets in. Every link stands on its own + until it is used or expires, and the rate limiter is what bounds how many can be outstanding. 2. The link lands on the **frontend** verify page, which POSTs the token. Email scanners only prefetch GETs, so they cannot burn the single-use token. 3. `POST /api/auth/verify {token}` — consumes the token atomically @@ -77,30 +77,33 @@ just dev # backend :8080 (dev profile) + frontend :5173 1. Create a test member (only needed until the sign-up form is wired to the backend): ```bash psql -h localhost -U postgres -d patchats -c \ - "INSERT INTO members (id, email, full_name, introduction, active) \ - VALUES (gen_random_uuid(), 'you@example.com', 'You', 'Testing locally', TRUE);" + "INSERT INTO members (id, first_name, last_name, email, introduction, active) \ + VALUES (gen_random_uuid(), 'You', 'Tester', 'you@example.com', 'Testing locally', TRUE);" ``` -2. Open `http://localhost:5173/login`, submit that email. (An **unregistered** email shows the same - generic panel, but the backend log shows no email composed — just the info-level skip.) +2. Open `http://localhost:5173/login`, submit that email. (An **unregistered** email instead gets a + 404 and the "No account for that email" panel, with a link to `/sign-up`; the backend log shows + no email composed.) 3. The dev profile does not send real email — `LoggingEmailSender` prints the full body to the **backend terminal**. Copy the `http://localhost:5173/auth/verify?token=...` URL from the log. 4. Open it: you land on `/`. Check DevTools → Application → Cookies for `patchats_session` (httpOnly, Lax, not Secure in dev). -5. Open the same link again → "invalid or expired" (single-use). Requesting a second link - invalidates the first. A 4th rapid request for the same email → the login page shows the 429 - message ("too many sign-in requests"), whether or not the email is registered. +5. Open the same link again → "invalid or expired" (single-use). Request a **second** link before + using the first, then open the first: it still signs you in — outstanding links are not + invalidated by a new one. A 4th rapid request for the same email → the login page shows the 429 + message ("too many sign-in requests"), whether or not the email is registered — an unregistered + address hits the 429 before the 404, which is the ordering that keeps probing throttled. 6. Log out from the header (visible on guarded pages like `/sample`); guarded routes now redirect to `/login`. ## Configuration -| Property | Env var | Default | Meaning | -| ------------------------ | -------------------- | ----------------------- | ---------------------------------------- | -| `app.auth.base-url` | `APP_BASE_URL` | `http://localhost:5173` | Public SPA origin used in emailed links | -| `app.auth.cookie-secure` | `AUTH_COOKIE_SECURE` | `true` (`false` in dev) | `Secure` flag on the session cookie | -| `app.auth.magic-link-ttl`| — | `15m` | Link validity window | -| `spring.session.timeout` | — | `30d` | Session inactivity timeout | +| Property | Env var | Default | Meaning | +| ------------------------- | -------------------- | ----------------------- | --------------------------------------- | +| `app.auth.base-url` | `APP_BASE_URL` | `http://localhost:5173` | Public SPA origin used in emailed links | +| `app.auth.cookie-secure` | `AUTH_COOKIE_SECURE` | `true` (`false` in dev) | `Secure` flag on the session cookie | +| `app.auth.magic-link-ttl` | — | `15m` | Link validity window | +| `spring.session.timeout` | — | `30d` | Session inactivity timeout | -Schema lives in Flyway (`db/migration/V0005`–`V0006`); `spring.session.jdbc.initialize-schema` is +Schema lives in Flyway (`db/migration/V0006`–`V0007`); `spring.session.jdbc.initialize-schema` is `never` so the app never races migrations, and runtime Flyway is disabled (migrations stay out-of-band via `just migrate`). diff --git a/docs/email-async/01-async-send-pipeline.md b/docs/email-async/01-async-send-pipeline.md index f37a6055..ee6ecbf1 100644 --- a/docs/email-async/01-async-send-pipeline.md +++ b/docs/email-async/01-async-send-pipeline.md @@ -5,7 +5,8 @@ actually deliver it — fully functional and testable via API + dev-profile logg See [00-overview.md](00-overview.md) for full context and the decision table. ## Decisions that apply here -- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table *is* the queue. + +- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table _is_ the queue. - **Render at send-time** (#4) — store `template_id` + `template_values`; the runner renders `subject`/`body` per row just before sending (rendered text is **not** stored). - **Single instance** (#5) — no row-locking needed; deploys must be **stop-then-start**. @@ -84,12 +85,14 @@ except where noted). Create `@Repository` classes: - `EmailTemplateRepo` — **read/list only** here (`findById`, `findAll`); writes arrive in Increment 5. Pattern: + ```java jdbcClient.sql("SELECT * FROM email_templates WHERE id = :id") .param("id", id) .query(new EmailTemplateRowMapper()) .optional(); ``` + Map `JSONB` (`template_values`) ↔ Java via a small Jackson helper. For the **N-child batch insert**, drop to the underlying `JdbcTemplate.batchUpdate(...)` (JdbcClient has no batch API yet) — inject `JdbcTemplate` only in `EmailRepo` for that one method. @@ -110,8 +113,8 @@ only in `EmailRepo` for that one method. happens later, in the runner (1e). - **The service does not start the runner.** After the `202` returns (transaction committed), the **caller** kicks the drain via `POST /api/email/process` (see below). This is the "manual/frontend kick only" model (#6). -- *(Optional)* dry-run render at enqueue for **early validation only** — reject a template that can't render up - front. Only the *values* are stored, never the output. Skip it for the minimal path; otherwise render errors +- _(Optional)_ dry-run render at enqueue for **early validation only** — reject a template that can't render up + front. Only the _values_ are stored, never the output. Skip it for the minimal path; otherwise render errors surface asynchronously as `ERROR` rows. - **Endpoints** (new `EmailAsyncController` or extend the existing controller): - `POST /api/email/send/async` → `enqueue(...)` with `source=MANUAL` → **`202 Accepted`** `{ requestId, accepted }`. @@ -136,9 +139,9 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos - **Bean:** a single-thread `ThreadPoolTaskExecutor` named `emailDrainExecutor` (core=max=1 so drains serialize and overlapping triggers coalesce), configured with `setWaitForTasksToCompleteOnShutdown(true)` - + an await timeout. `@EnableAsync` is already present on - [PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no** - `@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries). + - an await timeout. `@EnableAsync` is already present on + [PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no** + `@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries). - **`EmailDrainer.trigger()`** submits a drain job to `emailDrainExecutor` **only if one isn't already running** — guard with an `AtomicBoolean` via `compareAndSet`; if `trigger()` fires while a drain is running, set a `rerun` flag so the current drain loops again instead of exiting. @@ -148,9 +151,6 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos committed, the rows are already visible — no `AFTER_COMMIT` event is needed. **There is no automatic enqueue-time trigger** (the accepted tradeoff of #6: if the kick is never issued, the batch waits for the next kick or a restart). - 2. **On startup** — `@EventListener(ApplicationReadyEvent.class)` first resets `PROCESSING → ERROR` - (at-most-once recovery), then calls `trigger()` once (covers rows left `PENDING` before shutdown). This is the - only safety net for a missed kick. - **Drain job** (runs on the executor thread, loops until no rows, then the thread idles): 1. **Claim** up to 50 `PENDING` rows atomically: ```sql @@ -165,8 +165,8 @@ parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and pos [`EmailSender.send`](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java). On success → `status='SENT'`, `sent_at=now()` (**commit per row** — keeps any duplicate window to ≤1 email). On failure — including a **render failure** (template edited into an invalid state, missing variable) — → `status='ERROR'`, - `error_message=ex.getMessage()` (**no retry**). *(Cache templates per drain to avoid reloading the same one - for every row in a batch.)* + `error_message=ex.getMessage()` (**no retry**). _(Cache templates per drain to avoid reloading the same one + for every row in a batch.)_ 3. Re-claim; when a claim returns 0 rows, stop (honor the `rerun` flag if set). **Runner tradeoffs:** on-demand kick (manual/frontend API request) + single-instance + sequential is chosen @@ -181,6 +181,7 @@ or ShedLock for multi-instance. --- ## Files to touch + - **Create:** `db/migration/V0004__Create_email_tables.sql`; `email/` — `EmailRepo`, `EmailRequestRepo`, `EmailTemplateRepo`, row mappers, a Jackson JSONB helper; `EmailEnqueueService`, `dto/EnqueueEmailRequest`, `dto/EnqueueEmailResponse`; `RecipientSource` (+ CSV impl); @@ -191,6 +192,7 @@ or ShedLock for multi-instance. (add `/send/async`, `/templates` list, update `/preview`). ## Verification + - **Unit** (fake `EmailSender`, like [EmailServiceTest](../../src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java)): - `EmailEnqueueServiceTest` — enqueue stores `template_id` + `template_values` (no rendered output); an unknown diff --git a/js/src/features/auth/Login.page.test.tsx b/js/src/features/auth/Login.page.test.tsx index eacbd556..cbb29a9c 100644 --- a/js/src/features/auth/Login.page.test.tsx +++ b/js/src/features/auth/Login.page.test.tsx @@ -1,4 +1,7 @@ -import { rateLimitedResponse } from "@/features/auth/api/auth.mock"; +import { + rateLimitedResponse, + unregisteredEmailResponse, +} from "@/features/auth/api/auth.mock"; import LoginPage from "@/features/auth/Login.page"; import { renderWithProviders, screen } from "@/lib/test/render"; import { server } from "@/lib/test/server"; @@ -20,6 +23,35 @@ test("rejects an invalid email without calling the API", async () => { ).toBeInTheDocument(); }); +test("offers sign-up and a retry when the email has no account", async () => { + server.use( + http.post("/api/auth/request-link", () => unregisteredEmailResponse()), + ); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "stranger@example.com"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect( + await screen.findByText("No account for that email"), + ).toBeInTheDocument(); + expect(screen.getByText("stranger@example.com")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /complete the sign-up form/i }), + ).toHaveAttribute("href", "/sign-up"); + + await user.click( + screen.getByRole("button", { name: /try a different email/i }), + ); + + expect(await screen.findByLabelText(/email/i)).toHaveValue( + "stranger@example.com", + ); +}); + test("shows the generic check-your-email panel after submitting", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/js/src/features/auth/Login.page.tsx b/js/src/features/auth/Login.page.tsx index e25e29b0..d4224d65 100644 --- a/js/src/features/auth/Login.page.tsx +++ b/js/src/features/auth/Login.page.tsx @@ -16,9 +16,10 @@ import { zodResolver } from "mantine-form-zod-resolver"; import { Link } from "react-router-dom"; /** - * Passwordless login: ask for an email, request a magic link, and show the - * same "check your email" panel no matter what — account existence is never - * revealed here. + * Passwordless login: ask for an email and request a magic link. Three states — + * the form, "check your email" once a link is on its way, and a dead end for an + * address with no account, which offers the only two ways forward (another + * address, or sign up). */ export default function LoginPage() { const requestLink = useRequestLink(); @@ -28,10 +29,17 @@ export default function LoginPage() { validate: zodResolver(loginSchema), }); + const submittedEmail = form.getValues().email.trim(); + const handleSubmit = form.onSubmit((values) => { requestLink.mutate(values.email.trim()); }); + /** The backend 404s an email with no member row; every other failure falls + * through to the alert inside the form. */ + const isUnregistered = + requestLink.error instanceof ApiError && requestLink.error.status === 404; + if (requestLink.isSuccess) { return ( @@ -39,7 +47,7 @@ export default function LoginPage() { If you entered a valid address, a sign-in link is on its way to{" "} - {form.getValues().email.trim()} + {submittedEmail} . The link expires in 15 minutes and can only be used once. @@ -60,6 +68,31 @@ export default function LoginPage() { ); } + if (isUnregistered) { + return ( + + No account for that email + + We couldn't find a PatChats account for{" "} + + {submittedEmail} + + . Sign-in links are only sent to registered members. + + + + Never signed up?{" "} + + Complete the sign-up form + {" "} + to join. + + + ); + } + return (
diff --git a/js/src/features/auth/api/auth.mock.ts b/js/src/features/auth/api/auth.mock.ts index 11bea50b..7c5e33fd 100644 --- a/js/src/features/auth/api/auth.mock.ts +++ b/js/src/features/auth/api/auth.mock.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw"; /** * MSW handlers for the auth domain, envelope-shaped like the real backend. - * Defaults: request-link succeeds generically, verify signs in a member, and + * Defaults: request-link succeeds, verify signs in a member, and * there is no session (401). Tests override per case with `server.use(...)` * and the exported fixtures. */ @@ -25,6 +25,15 @@ export const invalidLinkResponse = () => { status: 400 }, ); +export const unregisteredEmailResponse = () => + HttpResponse.json( + { + success: false, + message: "We couldn't find an account for that email.", + }, + { status: 404 }, + ); + export const rateLimitedResponse = () => HttpResponse.json( { diff --git a/pom.xml b/pom.xml index c269b1f4..53b379b0 100644 --- a/pom.xml +++ b/pom.xml @@ -199,6 +199,19 @@ org.springframework.boot spring-boot-starter-jdbc + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.session + spring-session-jdbc + + + org.springframework.security + spring-security-test + test +