Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ patchats/
| Backend build | Maven (`./mvnw`) |
| Database | PostgreSQL + Flyway migrations |
| Data access | Spring JDBC (plain SQL — no ORM) |
| Auth | Spring Security + OAuth2 (no passwords stored) |
| Auth | Magic links + Spring Session JDBC cookie sessions (no passwords) — see `docs/auth-feature.md` |
| API docs | SpringDoc / OpenAPI → `/v3/api-docs` |
| Frontend language | TypeScript (strict mode) |
| Frontend framework | React 18, functional components + hooks |
Expand Down
109 changes: 109 additions & 0 deletions docs/auth-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Auth feature (magic links)

How PatChats signs members in: **magic links only** — no passwords, no OAuth. A user enters their
email, receives a single-use link, and clicking it establishes a server-side session delivered as an
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 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

```
src/main/java/org/patinanetwork/patchats/auth/
AuthController.java POST /api/auth/request-link | verify | logout, GET /api/session
AuthService.java request-link + verify orchestration
TokenGenerator.java SecureRandom 256-bit raw token + SHA-256 hex digest
MagicLinkEmailComposer.java builds the sign-in email via the EmailSender PORT
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/
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)
ApiAuthenticationEntryPoint.java 401s in the ApiResponder envelope

js/src/features/auth/
Login.page.tsx /login — email → generic "check your email" panel
Verify.page.tsx /auth/verify?token=... — POSTs the token once on mount
api/ useSession, useRequestLink, useVerifyMagicLink, useLogout, auth.mock.ts
```

## How a login works

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. 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
`<app.auth.base-url>/auth/verify?token=<raw>`. Links expire after 15 minutes
(`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
(`UPDATE .. WHERE consumed_at IS NULL AND expires_at > now RETURNING email`), resolves the
member (missing member → same generic invalid-link error), and performs a programmatic Spring
Security login. Spring Session JDBC
persists the session (`spring_session` tables) and sets the `patchats_session` cookie
(httpOnly, SameSite=Lax, Secure outside dev, 30-day Max-Age).
4. Sessions expire after 30 days of inactivity (`spring.session.timeout`, sliding) and are purged by
Spring Session's built-in cleanup job. `POST /api/auth/logout` invalidates the session row.

`GET /api/session` returns the member **fresh from the database** (never stale session state):
`{ id, name, email, isAdmin }`; 401 in the envelope when signed out. The frontend `RequireAuth`
guard sends signed-out visitors to `/login`.

**CSRF.** Double-submit protection (the Spring-documented SPA pattern): every response sets a
JS-readable `XSRF-TOKEN` cookie, and `apiFetch` echoes it back as an `X-XSRF-TOKEN` header on
state-changing requests. The two pre-auth endpoints (`request-link`, `verify`) are exempt — their
only credential travels in the body, and a first-time visitor has no CSRF cookie yet. Details and
rationale live in `SecurityConfig`'s javadoc.

## Manual test walkthrough (dev)

```bash
just migrate # needs local Postgres; .env points DATABASE_NAME at the patchats DB
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, 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 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). 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 |

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`).
21 changes: 13 additions & 8 deletions docs/email-async/01-async-send-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down Expand Up @@ -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.
Expand All @@ -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 }`.
Expand All @@ -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.
Expand All @@ -162,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
Expand All @@ -178,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);
Expand All @@ -188,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
Expand Down
13 changes: 12 additions & 1 deletion js/src/app/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { AppShell, Group, Text } from "@mantine/core";
import { useLogout } from "@/features/auth/api/useLogout";
import { AppShell, Button, Group, Text } from "@mantine/core";
import { Outlet } from "react-router-dom";

/** Chrome for authenticated pages: a header plus the routed page content. */
export function AppLayout() {
const logout = useLogout();

return (
<AppShell header={{ height: 56 }} padding="md">
<AppShell.Header>
<Group h="100%" justify="space-between" px="md">
<Text fw={700}>PatChats</Text>
<Button
variant="subtle"
size="compact-sm"
loading={logout.isPending}
onClick={() => logout.mutate()}
>
Log out
</Button>
</Group>
</AppShell.Header>
<AppShell.Main>
Expand Down
2 changes: 1 addition & 1 deletion js/src/app/router/guards/RequireAdmin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSession } from "@/lib/api/useSession";
import { useSession } from "@/features/auth/api/useSession";
import { Navigate, Outlet } from "react-router-dom";

/**
Expand Down
33 changes: 33 additions & 0 deletions js/src/app/router/guards/RequireAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { RequireAuth } from "@/app/router/guards/RequireAuth";
import { memberSession, sessionResponse } from "@/features/auth/api/auth.mock";
import { renderWithProviders, screen } from "@/lib/test/render";
import { server } from "@/lib/test/server";
import { http } from "msw";
import { Route, Routes } from "react-router-dom";
import { expect, test } from "vitest";

function renderGuarded() {
return renderWithProviders(
<Routes>
<Route element={<RequireAuth />}>
<Route path="/private" element={<div>private page</div>} />
</Route>
<Route path="/login" element={<div>login page</div>} />
</Routes>,
{ route: "/private" },
);
}

test("signed-out visitors are redirected to the login page", async () => {
renderGuarded();

expect(await screen.findByText("login page")).toBeInTheDocument();
});

test("a signed-in member sees the guarded page", async () => {
server.use(http.get("/api/session", () => sessionResponse(memberSession)));

renderGuarded();

expect(await screen.findByText("private page")).toBeInTheDocument();
});
10 changes: 5 additions & 5 deletions js/src/app/router/guards/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useSession } from "@/lib/api/useSession";
import { useSession } from "@/features/auth/api/useSession";
import { Center, Loader } from "@mantine/core";
import { Navigate, Outlet } from "react-router-dom";

/**
* Route guard: render the nested routes only for an authenticated user.
* While the session is loading, show a spinner; if there is no session, redirect
* to the public home.
* Route guard: render the nested routes only for an authenticated member.
* While the session is loading, show a spinner; signed-out visitors go to the
* login page.
*/
export function RequireAuth() {
const { data: session, isPending } = useSession();
Expand All @@ -19,7 +19,7 @@ export function RequireAuth() {
}

if (!session) {
return <Navigate replace to="/" />;
return <Navigate replace to="/login" />;
}

return <Outlet />;
Expand Down
4 changes: 4 additions & 0 deletions js/src/app/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { RequireAdmin } from "@/app/router/guards/RequireAdmin";
import { RequireAuth } from "@/app/router/guards/RequireAuth";
import AdminPage from "@/features/admin/Admin.page";
import AdminLoginPage from "@/features/admin/AdminLogin.page";
import LoginPage from "@/features/auth/Login.page";
import VerifyPage from "@/features/auth/Verify.page";
import EmailAdminPage from "@/features/emails/EmailAdminPage";
import { EmailHistoryDetailPage } from "@/features/emails/EmailHistoryDetailPage";
import { EmailHistoryPage } from "@/features/emails/EmailHistoryPage";
Expand Down Expand Up @@ -41,6 +43,8 @@ export const router = createBrowserRouter([
{ index: true, element: <HomePage /> },
{ path: "sign-up", element: <SignUpPage /> },
{ path: "profile/:id", element: <MemberProfilePage /> },
{ path: "login", element: <LoginPage /> },
{ path: "auth/verify", element: <VerifyPage /> },
],
},
// Temporary public email routes for TESTING (before auth is wired)
Expand Down
Loading
Loading