From 5e24b2d37d9c81f3e19addde4e5d135df8c36d9c Mon Sep 17 00:00:00 2001 From: Randy Dean Date: Mon, 6 Jul 2026 12:22:26 -0400 Subject: [PATCH 1/2] Auth: everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TokenGenerator mints 256-bit base64url tokens and stores only SHA-256 hex digests - MagicLinkTokenRepository (JdbcClient, plain SQL): insert plus an atomic consume that returns the email, uniform-empty on every failure mode - Injectable UTC Clock so expiry logic is testable Member lookups go through the member domain's MemberRepo — auth owns no member SQL of its own. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- docs/auth-feature.md | 106 +++++++++++++ js/src/app/layouts/AppLayout.tsx | 13 +- js/src/app/router/guards/RequireAdmin.tsx | 2 +- js/src/app/router/guards/RequireAuth.test.tsx | 33 +++++ js/src/app/router/guards/RequireAuth.tsx | 10 +- js/src/app/router/router.tsx | 4 + js/src/features/auth/Login.page.test.tsx | 51 +++++++ js/src/features/auth/Login.page.tsx | 103 +++++++++++++ js/src/features/auth/Verify.page.test.tsx | 45 ++++++ js/src/features/auth/Verify.page.tsx | 41 +++++ js/src/features/auth/api/auth.mock.ts | 59 ++++++++ js/src/features/auth/api/schemas.ts | 8 + js/src/features/auth/api/useLogout.ts | 18 +++ js/src/features/auth/api/useRequestLink.ts | 16 ++ js/src/features/auth/api/useSession.ts | 37 +++++ .../features/auth/api/useVerifyMagicLink.ts | 34 +++++ js/src/features/sample/api/sample.mock.ts | 6 +- js/src/lib/api/client.ts | 53 ++++++- js/src/lib/test/render.tsx | 16 +- js/src/lib/test/server.ts | 7 +- .../patchats/auth/AuthController.java | 121 +++++++++++++++ .../patchats/auth/AuthProperties.java | 22 +++ .../patchats/auth/AuthService.java | 76 ++++++++++ .../auth/InvalidMagicLinkException.java | 9 ++ .../patchats/auth/MagicLinkEmailComposer.java | 53 +++++++ .../patchats/auth/RequestLinkRateLimiter.java | 51 +++++++ .../patchats/auth/TokenGenerator.java | 41 +++++ .../auth/TooManyLinkRequestsException.java | 12 ++ .../patchats/auth/dto/RequestLinkRequest.java | 7 + .../patchats/auth/dto/SessionResponse.java | 15 ++ .../patchats/auth/dto/VerifyRequest.java | 6 + .../auth/repo/MagicLinkTokenRepository.java | 52 +++++++ .../security/ApiAuthenticationEntryPoint.java | 44 ++++++ .../auth/security/AuthenticatedMember.java | 23 +++ .../auth/security/SecurityConfig.java | 119 +++++++++++++++ .../security/SpaCsrfTokenRequestHandler.java | 41 +++++ .../patchats/common/config/ClockConfig.java | 15 ++ .../common/web/ApiExceptionHandler.java | 12 ++ .../patchats/auth/AuthControllerTest.java | 106 +++++++++++++ .../patchats/auth/AuthServiceTest.java | 124 ++++++++++++++++ .../auth/MagicLinkEmailComposerTest.java | 42 ++++++ .../auth/RequestLinkRateLimiterTest.java | 49 ++++++ .../patchats/auth/TokenGeneratorTest.java | 42 ++++++ .../auth/security/SecurityWiringTest.java | 140 ++++++++++++++++++ 45 files changed, 1864 insertions(+), 22 deletions(-) create mode 100644 docs/auth-feature.md create mode 100644 js/src/app/router/guards/RequireAuth.test.tsx create mode 100644 js/src/features/auth/Login.page.test.tsx create mode 100644 js/src/features/auth/Login.page.tsx create mode 100644 js/src/features/auth/Verify.page.test.tsx create mode 100644 js/src/features/auth/Verify.page.tsx create mode 100644 js/src/features/auth/api/auth.mock.ts create mode 100644 js/src/features/auth/api/schemas.ts create mode 100644 js/src/features/auth/api/useLogout.ts create mode 100644 js/src/features/auth/api/useRequestLink.ts create mode 100644 js/src/features/auth/api/useSession.ts create mode 100644 js/src/features/auth/api/useVerifyMagicLink.ts create mode 100644 src/main/java/org/patinanetwork/patchats/auth/AuthController.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/AuthService.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/security/SpaCsrfTokenRequestHandler.java create mode 100644 src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java diff --git a/AGENTS.md b/AGENTS.md index 013a0d6e..6db1658a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/docs/auth-feature.md b/docs/auth-feature.md new file mode 100644 index 00000000..5d664e2e --- /dev/null +++ b/docs/auth-feature.md @@ -0,0 +1,106 @@ +# 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 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). + +## 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/ + MagicLinkTokenRepository.java JdbcClient; atomic UPDATE..RETURNING consume + MemberAccountRepository.java auth's read-only view of members (findByEmail, findById) + 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. 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 + fresh 256-bit token (raw is never persisted), and emails + `/auth/verify?token=`. Links expire after 15 minutes + (`app.auth.magic-link-ttl`). +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, email, full_name, introduction, active) \ + VALUES (gen_random_uuid(), 'you@example.com', 'You', '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.) +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. +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/V0005`–`V0006`); `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/js/src/app/layouts/AppLayout.tsx b/js/src/app/layouts/AppLayout.tsx index 51652142..af04e4c9 100644 --- a/js/src/app/layouts/AppLayout.tsx +++ b/js/src/app/layouts/AppLayout.tsx @@ -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 ( PatChats + diff --git a/js/src/app/router/guards/RequireAdmin.tsx b/js/src/app/router/guards/RequireAdmin.tsx index feb7e09c..c1277689 100644 --- a/js/src/app/router/guards/RequireAdmin.tsx +++ b/js/src/app/router/guards/RequireAdmin.tsx @@ -1,4 +1,4 @@ -import { useSession } from "@/lib/api/useSession"; +import { useSession } from "@/features/auth/api/useSession"; import { Navigate, Outlet } from "react-router-dom"; /** diff --git a/js/src/app/router/guards/RequireAuth.test.tsx b/js/src/app/router/guards/RequireAuth.test.tsx new file mode 100644 index 00000000..6dc3a049 --- /dev/null +++ b/js/src/app/router/guards/RequireAuth.test.tsx @@ -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( + + }> + private page} /> + + login page} /> + , + { 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(); +}); diff --git a/js/src/app/router/guards/RequireAuth.tsx b/js/src/app/router/guards/RequireAuth.tsx index db3e9774..880fce5c 100644 --- a/js/src/app/router/guards/RequireAuth.tsx +++ b/js/src/app/router/guards/RequireAuth.tsx @@ -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(); @@ -19,7 +19,7 @@ export function RequireAuth() { } if (!session) { - return ; + return ; } return ; diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx index faaa0452..25f3d099 100644 --- a/js/src/app/router/router.tsx +++ b/js/src/app/router/router.tsx @@ -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"; @@ -41,6 +43,8 @@ export const router = createBrowserRouter([ { index: true, element: }, { path: "sign-up", element: }, { path: "profile/:id", element: }, + { path: "login", element: }, + { path: "auth/verify", element: }, ], }, // Temporary public email routes for TESTING (before auth is wired) diff --git a/js/src/features/auth/Login.page.test.tsx b/js/src/features/auth/Login.page.test.tsx new file mode 100644 index 00000000..eacbd556 --- /dev/null +++ b/js/src/features/auth/Login.page.test.tsx @@ -0,0 +1,51 @@ +import { rateLimitedResponse } 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"; +import userEvent from "@testing-library/user-event"; +import { http } from "msw"; +import { expect, test } from "vitest"; + +test("rejects an invalid email without calling the API", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "not-an-email"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect( + await screen.findByText("Enter a valid email address"), + ).toBeInTheDocument(); +}); + +test("shows the generic check-your-email panel after submitting", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "ann@example.com"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect(await screen.findByText("Check your email")).toBeInTheDocument(); + expect(screen.getByText("ann@example.com")).toBeInTheDocument(); +}); + +test("surfaces the server's message when rate limited", async () => { + server.use(http.post("/api/auth/request-link", () => rateLimitedResponse())); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText(/email/i), "ann@example.com"); + await user.click( + screen.getByRole("button", { name: /email me a sign-in link/i }), + ); + + expect( + await screen.findByText( + "Too many sign-in requests. Please wait a few minutes and try again.", + ), + ).toBeInTheDocument(); +}); diff --git a/js/src/features/auth/Login.page.tsx b/js/src/features/auth/Login.page.tsx new file mode 100644 index 00000000..e25e29b0 --- /dev/null +++ b/js/src/features/auth/Login.page.tsx @@ -0,0 +1,103 @@ +import { loginSchema, LoginFormValues } from "@/features/auth/api/schemas"; +import { useRequestLink } from "@/features/auth/api/useRequestLink"; +import { ApiError } from "@/lib/api/client"; +import { + Alert, + Anchor, + Button, + Paper, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +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. + */ +export default function LoginPage() { + const requestLink = useRequestLink(); + + const form = useForm({ + initialValues: { email: "" }, + validate: zodResolver(loginSchema), + }); + + const handleSubmit = form.onSubmit((values) => { + requestLink.mutate(values.email.trim()); + }); + + if (requestLink.isSuccess) { + return ( + + Check your email + + If you entered a valid address, a sign-in link is on its way to{" "} + + {form.getValues().email.trim()} + + . The link expires in 15 minutes and can only be used once. + + + Nothing arriving? Check your spam folder, or{" "} + requestLink.reset()} + > + request another link + + . + + + ); + } + + return ( + +
+ + Sign in to PatChats + + Enter your email and we'll send you a sign-in link — no + password needed. + + + {requestLink.isError && ( + + {( + requestLink.error instanceof ApiError && + requestLink.error.status === 429 + ) ? + requestLink.error.message + : "Something went wrong sending your link. Please try again."} + + )} + + + New to PatChats?{" "} + + Complete the sign-up form + {" "} + first — sign-in links are only sent to registered members. + + +
+
+ ); +} diff --git a/js/src/features/auth/Verify.page.test.tsx b/js/src/features/auth/Verify.page.test.tsx new file mode 100644 index 00000000..a3a10977 --- /dev/null +++ b/js/src/features/auth/Verify.page.test.tsx @@ -0,0 +1,45 @@ +import { invalidLinkResponse } from "@/features/auth/api/auth.mock"; +import VerifyPage from "@/features/auth/Verify.page"; +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"; + +/** Mounts the verify page plus probe routes so navigation can be asserted. */ +function renderVerify(url: string) { + return renderWithProviders( + + } /> + home page} /> + , + { route: url }, + ); +} + +test("a verified member lands on the home page", async () => { + renderVerify("/auth/verify?token=good-token"); + + expect(await screen.findByText("home page")).toBeInTheDocument(); +}); + +test("an invalid or expired link shows the server message and a retry path", async () => { + server.use(http.post("/api/auth/verify", () => invalidLinkResponse())); + + renderVerify("/auth/verify?token=spent-token"); + + expect( + await screen.findByText( + "This sign-in link is invalid or has expired. Request a new one.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /request a new link/i }), + ).toBeInTheDocument(); +}); + +test("a missing token shows the error state without calling the API", async () => { + renderVerify("/auth/verify"); + + expect(await screen.findByText("Sign-in link problem")).toBeInTheDocument(); +}); diff --git a/js/src/features/auth/Verify.page.tsx b/js/src/features/auth/Verify.page.tsx new file mode 100644 index 00000000..df2ed37a --- /dev/null +++ b/js/src/features/auth/Verify.page.tsx @@ -0,0 +1,41 @@ +import { useVerifyMagicLink } from "@/features/auth/api/useVerifyMagicLink"; +import { Alert, Button, Center, Loader, Stack, Text } from "@mantine/core"; +import { Link, Navigate, useSearchParams } from "react-router-dom"; + +/** + * Landing page for the emailed link (`/auth/verify?token=...`). The link is a + * plain GET so email scanners can prefetch it harmlessly; the actual + * single-use consumption happens here via POST when the page mounts. + */ +export default function VerifyPage() { + const [params] = useSearchParams(); + const token = params.get("token"); + const verify = useVerifyMagicLink(token); + + if (verify.isSuccess) { + return ; + } + + if (!token || verify.isError) { + return ( + + + {verify.error?.message ?? + "This sign-in link is invalid or has expired. Request a new one."} + + + + ); + } + + return ( +
+ + + Signing you in… + +
+ ); +} diff --git a/js/src/features/auth/api/auth.mock.ts b/js/src/features/auth/api/auth.mock.ts new file mode 100644 index 00000000..11bea50b --- /dev/null +++ b/js/src/features/auth/api/auth.mock.ts @@ -0,0 +1,59 @@ +import { Session } from "@/features/auth/api/useSession"; +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 + * there is no session (401). Tests override per case with `server.use(...)` + * and the exported fixtures. + */ + +export const memberSession: Session = { + id: "6f9a4f4e-0000-4000-8000-000000000001", + name: "Ann Example", + email: "ann@example.com", + isAdmin: false, +}; + +export const invalidLinkResponse = () => + HttpResponse.json( + { + success: false, + message: + "This sign-in link is invalid or has expired. Request a new one.", + }, + { status: 400 }, + ); + +export const rateLimitedResponse = () => + HttpResponse.json( + { + success: false, + message: + "Too many sign-in requests. Please wait a few minutes and try again.", + }, + { status: 429 }, + ); + +export const sessionResponse = (session: Session) => + HttpResponse.json({ success: true, message: "Signed in.", payload: session }); + +export const authHandlers = [ + http.post("/api/auth/request-link", () => + HttpResponse.json({ + success: true, + message: "Check your email for a sign-in link.", + payload: null, + }), + ), + http.post("/api/auth/verify", () => sessionResponse(memberSession)), + http.get("/api/session", () => + HttpResponse.json( + { success: false, message: "Not signed in" }, + { status: 401 }, + ), + ), + http.post("/api/auth/logout", () => + HttpResponse.json({ success: true, message: "Signed out.", payload: null }), + ), +]; diff --git a/js/src/features/auth/api/schemas.ts b/js/src/features/auth/api/schemas.ts new file mode 100644 index 00000000..f105d2e8 --- /dev/null +++ b/js/src/features/auth/api/schemas.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +/** Login form: just an email — magic links are the only sign-in method. */ +export const loginSchema = z.object({ + email: z.string().trim().email("Enter a valid email address"), +}); + +export type LoginFormValues = z.infer; diff --git a/js/src/features/auth/api/useLogout.ts b/js/src/features/auth/api/useLogout.ts new file mode 100644 index 00000000..cb4a2390 --- /dev/null +++ b/js/src/features/auth/api/useLogout.ts @@ -0,0 +1,18 @@ +import { sessionQueryKey } from "@/features/auth/api/useSession"; +import { apiFetch } from "@/lib/api/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; + +/** Signs out: the backend invalidates the session and expires the cookie. */ +export function useLogout() { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + return useMutation({ + mutationFn: () => apiFetch("/auth/logout", { method: "POST" }), + onSuccess: () => { + queryClient.setQueryData(sessionQueryKey, null); + navigate("/"); + }, + }); +} diff --git a/js/src/features/auth/api/useRequestLink.ts b/js/src/features/auth/api/useRequestLink.ts new file mode 100644 index 00000000..eaa5b8bd --- /dev/null +++ b/js/src/features/auth/api/useRequestLink.ts @@ -0,0 +1,16 @@ +import { apiFetch } from "@/lib/api/client"; +import { useMutation } from "@tanstack/react-query"; + +/** + * Asks the backend to email a sign-in link. The response is intentionally + * identical whether or not the email has an account. + */ +export function useRequestLink() { + return useMutation({ + mutationFn: (email: string) => + apiFetch("/auth/request-link", { + method: "POST", + body: JSON.stringify({ email }), + }), + }); +} diff --git a/js/src/features/auth/api/useSession.ts b/js/src/features/auth/api/useSession.ts new file mode 100644 index 00000000..07c52c2a --- /dev/null +++ b/js/src/features/auth/api/useSession.ts @@ -0,0 +1,37 @@ +import { ApiError, apiFetch } from "@/lib/api/client"; +import { useQuery } from "@tanstack/react-query"; + +/** + * The current authenticated member. Members are created exclusively by the + * sign-up form, so a session always carries a complete profile. + */ +export interface Session { + id: string; + name: string; + email: string; + isAdmin: boolean; +} + +export const sessionQueryKey = ["session"] as const; + +/** + * Fetches the session from the cookie-backed backend. A 401 resolves to + * `null` — "signed out" is data, not an error, so guards can branch on it. + */ +export function useSession() { + return useQuery({ + queryKey: sessionQueryKey, + queryFn: async () => { + try { + return await apiFetch("/session"); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return null; + } + throw error; + } + }, + retry: false, + staleTime: Infinity, + }); +} diff --git a/js/src/features/auth/api/useVerifyMagicLink.ts b/js/src/features/auth/api/useVerifyMagicLink.ts new file mode 100644 index 00000000..fe390071 --- /dev/null +++ b/js/src/features/auth/api/useVerifyMagicLink.ts @@ -0,0 +1,34 @@ +import { Session, sessionQueryKey } from "@/features/auth/api/useSession"; +import { apiFetch } from "@/lib/api/client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +/** + * Exchanges the raw token from the emailed link for a session. On success the + * backend sets the session cookie; we also seed the session cache so guards + * pass without a second round-trip. + * + * Modeled as a query (not a mutation) on purpose: the token is single-use and + * this fires on mount, and StrictMode's simulated remount detaches a mutation + * observer from its in-flight request — the component would never see the + * result. A query keyed by the token is deduped across the double-mount (one + * POST) and the remounted observer re-attaches to the cached entry. + */ +export function useVerifyMagicLink(token: string | null) { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: ["auth", "verify", token], + queryFn: async () => { + const session = await apiFetch("/auth/verify", { + method: "POST", + body: JSON.stringify({ token }), + }); + queryClient.setQueryData(sessionQueryKey, session); + return session; + }, + enabled: token !== null, + retry: false, + // Never refetch a consumed token: the entry stays fresh for the page's lifetime. + staleTime: Infinity, + }); +} diff --git a/js/src/features/sample/api/sample.mock.ts b/js/src/features/sample/api/sample.mock.ts index 9bb53db0..13f4d78b 100644 --- a/js/src/features/sample/api/sample.mock.ts +++ b/js/src/features/sample/api/sample.mock.ts @@ -6,6 +6,10 @@ import { http, HttpResponse } from "msw"; */ export const sampleHandlers = [ http.get("/api/sample/message", () => - HttpResponse.json({ message: "Hello from MSW" }), + HttpResponse.json({ + success: true, + message: "", + payload: { message: "Hello from MSW" }, + }), ), ]; diff --git a/js/src/lib/api/client.ts b/js/src/lib/api/client.ts index 6bdff2c4..98fa5c11 100644 --- a/js/src/lib/api/client.ts +++ b/js/src/lib/api/client.ts @@ -1,9 +1,16 @@ /** * Thin typed fetch wrapper over the backend API (proxied at `/api` in dev). * - * This is the single place to handle JSON parsing, error mapping, and (later) - * auth headers. Once `schema.d.ts` is generated from the OpenAPI spec, the - * generics here can be tightened against `paths`. + * Every backend response is wrapped in the ApiResponder envelope + * (`{ success, message, payload }`); this is the single place that unwraps it, + * so hooks receive typed payloads and errors carry the server's message. + * Auth rides on an httpOnly session cookie, hence `credentials: "same-origin"` + * (the SPA and API share an origin — the Vite proxy provides that in dev). + * + * CSRF: the backend sets a JS-readable `XSRF-TOKEN` cookie on every response; + * state-changing requests must echo it back as an `X-XSRF-TOKEN` header + * (double-submit pattern). The pre-auth endpoints (request-link, verify) are + * exempt server-side, so a missing cookie on first visit is fine. */ export interface ApiResponder { @@ -21,18 +28,52 @@ export class ApiError extends Error { } } +interface ApiEnvelope { + success: boolean; + message?: string; + payload?: T; +} + +function readCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.slice(name.length + 1); +} + +function csrfHeader(method: string): Record { + if (method === "GET" || method === "HEAD") { + return {}; + } + const token = readCookie("XSRF-TOKEN"); + return token ? { "X-XSRF-TOKEN": decodeURIComponent(token) } : {}; +} + export async function apiFetch( path: string, init?: RequestInit, ): Promise { + const method = (init?.method ?? "GET").toUpperCase(); const response = await fetch(`/api${path}`, { + credentials: "same-origin", ...init, - headers: { "Content-Type": "application/json", ...init?.headers }, + headers: { + "Content-Type": "application/json", + ...csrfHeader(method), + ...init?.headers, + }, }); + const envelope = (await response.json().catch(() => undefined)) as + | ApiEnvelope + | undefined; + if (!response.ok) { - throw new ApiError(response.status, `Request to ${path} failed`); + throw new ApiError( + response.status, + envelope?.message ?? `Request to ${path} failed`, + ); } - return response.json() as Promise; + return envelope?.payload as T; } diff --git a/js/src/lib/test/render.tsx b/js/src/lib/test/render.tsx index 031c84c8..cc33a2f2 100644 --- a/js/src/lib/test/render.tsx +++ b/js/src/lib/test/render.tsx @@ -2,7 +2,7 @@ import { themeOverride } from "@/app/providers/theme"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, RenderOptions } from "@testing-library/react"; -import { ReactElement, ReactNode } from "react"; +import { ReactElement, ReactNode, StrictMode } from "react"; import { MemoryRouter } from "react-router-dom"; /** @@ -14,13 +14,17 @@ function createWrapper(route: string) { defaultOptions: { queries: { retry: false } }, }); + // StrictMode mirrors main.tsx: double-invoked effects surface bugs (e.g. + // observers detached from in-flight requests) that a bare render hides. return function Wrapper({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + + {children} + + + ); }; } diff --git a/js/src/lib/test/server.ts b/js/src/lib/test/server.ts index f51ed1c8..36c67967 100644 --- a/js/src/lib/test/server.ts +++ b/js/src/lib/test/server.ts @@ -1,3 +1,4 @@ +import { authHandlers } from "@/features/auth/api/auth.mock"; import { membersHandlers } from "@/features/members/api/members.mock"; import { sampleHandlers } from "@/features/sample/api/sample.mock"; import { setupServer } from "msw/node"; @@ -6,4 +7,8 @@ import { setupServer } from "msw/node"; * MSW server for tests. Each domain owns its request handlers in * `features//api/.mock.ts`; compose them here. */ -export const server = setupServer(...membersHandlers, ...sampleHandlers); +export const server = setupServer( + ...sampleHandlers, + ...authHandlers, + ...membersHandlers, +); diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthController.java b/src/main/java/org/patinanetwork/patchats/auth/AuthController.java new file mode 100644 index 00000000..04b5c964 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthController.java @@ -0,0 +1,121 @@ +package org.patinanetwork.patchats.auth; + +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.auth.dto.RequestLinkRequest; +import org.patinanetwork.patchats.auth.dto.SessionResponse; +import org.patinanetwork.patchats.auth.dto.VerifyRequest; +import org.patinanetwork.patchats.auth.security.AuthenticatedMember; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REST endpoints for magic-link sign-in and the current session. */ +@RestController +@RequestMapping("/api") +@Tag(name = "Auth") +@Timed(value = "controller.execution") +@EnableConfigurationProperties(AuthProperties.class) +@RequiredArgsConstructor +public class AuthController { + + /** The response is identical whether or not the email has an account, so nothing can be enumerated. */ + private static final String GENERIC_REQUEST_MESSAGE = "Check your email for a sign-in link."; + + private final AuthService authService; + private final MemberRepo members; + private final SecurityContextRepository securityContextRepository; + + @Operation(summary = "Email a single-use sign-in link") + @PostMapping("/auth/request-link") + public ResponseEntity> requestLink( + @Valid @RequestBody final RequestLinkRequest request, final HttpServletRequest httpRequest) { + authService.requestLink(request.email(), httpRequest.getRemoteAddr()); + return ResponseEntity.ok(ApiResponder.success(GENERIC_REQUEST_MESSAGE, null)); + } + + @Operation(summary = "Exchange a magic-link token for a session") + @PostMapping("/auth/verify") + public ResponseEntity> verify( + @Valid @RequestBody final VerifyRequest request, + final HttpServletRequest httpRequest, + final HttpServletResponse httpResponse) { + final Member member = authService.verify(request.token()); + login(member, httpRequest, httpResponse); + return ResponseEntity.ok(ApiResponder.success("Signed in.", SessionResponse.of(member))); + } + + /** + * Reads the member fresh from the database so a deleted member is never served from stale session state; in that + * case the session is torn down (row invalidated and the thread-local context cleared, so nothing later in this + * request still sees an authenticated principal). + * + *

Looks up by email rather than id because {@code MemberRepo.getMemberById} is still + * {@code UnsupportedOperationException} — switch to it once the member domain implements it. The email in the + * principal is the value read straight off the member row at sign-in, so it matches exactly. + */ + @Operation(summary = "The currently signed-in member") + @GetMapping("/session") + public ResponseEntity> session( + @AuthenticationPrincipal final AuthenticatedMember principal, final HttpServletRequest httpRequest) { + return members.getMemberByEmail(principal.email()) + .map(account -> ResponseEntity.ok(ApiResponder.success("Signed in.", SessionResponse.of(account)))) + .orElseGet(() -> { + invalidateSession(httpRequest); + SecurityContextHolder.clearContext(); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponder.failure("Not signed in")); + }); + } + + @Operation(summary = "Sign out") + @PostMapping("/auth/logout") + public ResponseEntity> logout(final HttpServletRequest httpRequest) { + invalidateSession(httpRequest); + SecurityContextHolder.clearContext(); + return ResponseEntity.ok(ApiResponder.success("Signed out.", null)); + } + + /** + * Programmatic login: store the authenticated principal in the {@link SecurityContextRepository}, which Spring + * Session persists and turns into the session cookie. {@code changeSessionId} rotates any pre-existing session so a + * client-supplied id can never survive into an authenticated session (fixation defense). + */ + private void login(final Member member, final HttpServletRequest request, final HttpServletResponse response) { + if (request.getSession(false) != null) { + request.changeSessionId(); + } + final SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( + AuthenticatedMember.of(member), null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + } + + private void invalidateSession(final HttpServletRequest request) { + final HttpSession session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java new file mode 100644 index 00000000..64f774ec --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java @@ -0,0 +1,22 @@ +package org.patinanetwork.patchats.auth; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Auth configuration, bound from {@code app.auth.*}. */ +@ConfigurationProperties(prefix = "app.auth") +@Getter +@Setter +public class AuthProperties { + + /** Public origin of the SPA; magic links point at {@code /auth/verify?token=...}. */ + private String baseUrl; + + /** Whether the session cookie carries the {@code Secure} flag. Off only for plain-HTTP dev. */ + private boolean cookieSecure = true; + + /** How long an emailed magic link stays valid. */ + private Duration magicLinkTtl = Duration.ofMinutes(15); +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthService.java b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java new file mode 100644 index 00000000..fbb5f3c7 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java @@ -0,0 +1,76 @@ +package org.patinanetwork.patchats.auth; + +import java.time.Clock; +import java.util.Locale; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken; +import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository; +import org.springframework.stereotype.Service; + +/** + * Orchestrates the magic-link flow: issuing links (request) and exchanging them for a member (verify). Magic links only + * sign in existing members — the sign-up form is the sole creator of member rows — and requesting a link never leaks + * whether an account exists. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final MagicLinkTokenRepository tokens; + private final MemberRepo members; + private final TokenGenerator tokenGenerator; + private final MagicLinkEmailComposer emailComposer; + private final RequestLinkRateLimiter rateLimiter; + private final AuthProperties properties; + private final Clock clock; + + /** + * Issues a fresh single-use link and invalidates any outstanding ones for the email. Being rate-limited surfaces as + * a visible 429 — the limiter runs before the member-existence check, so the 429 is registration-blind and + * reveals nothing. Unregistered emails are skipped silently (the same generic success as a real send), so account + * existence stays unobservable. + * + * @throws TooManyLinkRequestsException when the per-email or per-IP budget is exhausted + */ + public void requestLink(final String rawEmail, final String clientIp) { + final String email = normalize(rawEmail); + if (!rateLimiter.tryAcquire(email, clientIp)) { + log.warn("Rate-limited magic-link request for {} from {}", email, clientIp); + throw new TooManyLinkRequestsException(); + } + if (members.getMemberByEmail(email).isEmpty()) { + log.info("Skipping magic-link request for unregistered email {}", email); + return; + } + final GeneratedToken token = tokenGenerator.generate(); + tokens.deleteByEmail(email); + tokens.insertToken( + UUID.randomUUID(), email, token.hash(), clock.instant().plus(properties.getMagicLinkTtl())); + emailComposer.send(email, token.raw()); + } + + /** + * Atomically consumes the presented token and resolves the member behind it. + * + * @throws InvalidMagicLinkException when the token is unknown, already used, or expired — or when the member no + * longer exists (deleted between send and click); the message stays generic either way + */ + public Member verify(final String rawToken) { + final String email = tokens.consumeAndReturnEmail(TokenGenerator.hash(rawToken), clock.instant()) + .orElseThrow(InvalidMagicLinkException::new); + return members.getMemberByEmail(email).orElseThrow(InvalidMagicLinkException::new); + } + + /** + * Lowercases and trims before any lookup or token write. {@code MemberRepo.getMemberByEmail} matches the column + * exactly, so this is the single place email casing is reconciled — every path through this service must use it. + */ + private static String normalize(final String email) { + return email.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java b/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java new file mode 100644 index 00000000..cb356854 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java @@ -0,0 +1,9 @@ +package org.patinanetwork.patchats.auth; + +/** Thrown when a presented magic-link token is unknown, already used, or expired. Maps to a 400 failure envelope. */ +public class InvalidMagicLinkException extends RuntimeException { + + public InvalidMagicLinkException() { + super("This sign-in link is invalid or has expired. Request a new one."); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java b/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java new file mode 100644 index 00000000..1a25430b --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java @@ -0,0 +1,53 @@ +package org.patinanetwork.patchats.auth; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.email.EmailSender; +import org.patinanetwork.patchats.email.OutgoingEmail; +import org.patinanetwork.patchats.email.TemplateRenderer; +import org.springframework.stereotype.Component; + +/** + * Builds and delivers the sign-in email. Goes through the {@link EmailSender} port directly (not {@code EmailService}, + * whose batch request/response shape is for admin-triggered sends), so the dev profile's logging sender prints the full + * body — including the link — to the backend console. + */ +@Component +@RequiredArgsConstructor +public class MagicLinkEmailComposer { + + private static final String SUBJECT = "Your PatChats sign-in link"; + private static final String BODY_TEMPLATE = """ + Hi, + + Click this link to sign in to PatChats: + + ${link} + + The link expires in ${ttlMinutes} minutes and can only be used once. + + If you didn't request this, you can safely ignore this email."""; + + private final TemplateRenderer renderer; + private final EmailSender sender; + private final AuthProperties properties; + + public void send(final String email, final String rawToken) { + final String baseUrl = trimTrailingSlash(properties.getBaseUrl()); + final String link = "%s/auth/verify?token=%s".formatted(baseUrl, rawToken); + final String body = renderer.render( + BODY_TEMPLATE, + Map.of( + "link", + link, + "ttlMinutes", + String.valueOf(properties.getMagicLinkTtl().toMinutes()))); + sender.send(new OutgoingEmail(List.of(email), SUBJECT, body, Optional.empty())); + } + + private static String trimTrailingSlash(final String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java new file mode 100644 index 00000000..c8f760d0 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java @@ -0,0 +1,51 @@ +package org.patinanetwork.patchats.auth; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import io.github.bucket4j.Bucket; +import java.time.Duration; +import org.springframework.stereotype.Component; + +/** + * Guards the request-link endpoint against inbox flooding: a small per-email budget plus a looser per-IP budget, both + * refilling over a 15-minute window. Buckets live in memory (bounded by an expire-after-access cache), which is + * per-instance and fine for the current single-node deployment; Bucket4j's distributed backends are the upgrade path if + * that changes. + */ +@Component +public class RequestLinkRateLimiter { + + private static final int EMAIL_CAPACITY = 3; + private static final int IP_CAPACITY = 10; + private static final Duration WINDOW = Duration.ofMinutes(15); + + private final LoadingCache emailBuckets = buckets(EMAIL_CAPACITY); + private final LoadingCache ipBuckets = buckets(IP_CAPACITY); + + /** + * Consumes one request from both budgets; permitted only when neither is exhausted. If the IP budget denies after + * the email budget consumed, the email token is returned — a request blocked by one limit must not silently drain + * the other. + */ + public boolean tryAcquire(final String email, final String clientIp) { + final Bucket emailBucket = emailBuckets.getUnchecked(email); + final Bucket ipBucket = ipBuckets.getUnchecked(clientIp); + if (!emailBucket.tryConsume(1)) { + return false; + } + if (ipBucket.tryConsume(1)) { + return true; + } + emailBucket.addTokens(1); + return false; + } + + private static LoadingCache buckets(final int capacity) { + return CacheBuilder.newBuilder() + .expireAfterAccess(WINDOW.multipliedBy(2)) + .build(CacheLoader.from(key -> Bucket.builder() + .addLimit(limit -> limit.capacity(capacity).refillGreedy(capacity, WINDOW)) + .build())); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java new file mode 100644 index 00000000..c449a629 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java @@ -0,0 +1,41 @@ +package org.patinanetwork.patchats.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +/** + * Mints the opaque magic-link tokens. The raw token goes into the emailed link and is never persisted; only its SHA-256 + * hex digest is stored, so a database leak cannot be replayed as a login. + */ +@Component +public class TokenGenerator { + + private static final int TOKEN_BYTES = 32; + + private final SecureRandom secureRandom = new SecureRandom(); + + /** A freshly minted token: {@code raw} for the email link, {@code hash} for the database. */ + public record GeneratedToken(String raw, String hash) {} + + public GeneratedToken generate() { + final byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + final String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + return new GeneratedToken(raw, hash(raw)); + } + + /** SHA-256 hex digest of a raw token, used to look up what the client presents. */ + public static String hash(final String rawToken) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(rawToken.getBytes(StandardCharsets.UTF_8))); + } catch (final NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java b/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java new file mode 100644 index 00000000..f68f84a0 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java @@ -0,0 +1,12 @@ +package org.patinanetwork.patchats.auth; + +/** + * Thrown when the request-link rate limit is hit. Maps to HTTP 429 with a friendly message. The limiter runs before the + * member-existence check, so the 429 is registration-blind — it reveals nothing about whether the email has an account. + */ +public class TooManyLinkRequestsException extends RuntimeException { + + public TooManyLinkRequestsException() { + super("Too many sign-in requests. Please wait a few minutes and try again."); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java b/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java new file mode 100644 index 00000000..f708c9d2 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** Body of {@code POST /api/auth/request-link}. */ +public record RequestLinkRequest(@NotBlank @Email String email) {} diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java b/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java new file mode 100644 index 00000000..c573c5ac --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.auth.dto; + +import org.patinanetwork.patchats.api.member.db.models.Member; + +/** + * The signed-in member as seen by the frontend. Members always have a complete profile (the sign-up form is the only + * way one is created), so {@code name} is always present. {@code isAdmin} is always false until the admin domain lands. + */ +public record SessionResponse(String id, String name, String email, boolean isAdmin) { + + public static SessionResponse of(final Member member) { + final String name = "%s %s".formatted(member.getFirstName(), member.getLastName()); + return new SessionResponse(member.getId().toString(), name, member.getEmail(), false); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java b/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java new file mode 100644 index 00000000..fb3856ad --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java @@ -0,0 +1,6 @@ +package org.patinanetwork.patchats.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** Body of {@code POST /api/auth/verify}: the raw token from the emailed link. */ +public record VerifyRequest(@NotBlank String token) {} diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java new file mode 100644 index 00000000..2adda9e1 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java @@ -0,0 +1,52 @@ +package org.patinanetwork.patchats.auth.repo; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** Plain-SQL access to {@code magic_link_tokens}. Only token hashes ever touch this table. */ +@Repository +@RequiredArgsConstructor +public class MagicLinkTokenRepository { + + private final JdbcClient jdbc; + + /** Invalidates every outstanding link for an email; called before issuing a new one. */ + public void deleteByEmail(final String email) { + jdbc.sql("DELETE FROM magic_link_tokens WHERE email = :email") + .param("email", email) + .update(); + } + + public void insertToken(final UUID id, final String email, final String tokenHash, final Instant expiresAt) { + jdbc.sql("INSERT INTO magic_link_tokens (id, email, token_hash, expires_at)" + + " VALUES (:id, :email, :tokenHash, :expiresAt)") + .param("id", id) + .param("email", email) + .param("tokenHash", tokenHash) + .param("expiresAt", expiresAt.atOffset(ZoneOffset.UTC)) + .update(); + } + + /** + * Atomically consumes an unexpired, unused token and returns the email it was issued to. The single UPDATE + * guarantees a token can only ever log in one caller, even under concurrent requests. + * + *

Returns {@link Optional#empty()} for unknown, already-consumed, and expired tokens alike — the + * uniformity is deliberate: callers surface one generic "invalid or expired" outcome, so presenting tokens never + * becomes an oracle for which failure occurred or whether an email exists in the system. + */ + public Optional consumeAndReturnEmail(final String tokenHash, final Instant now) { + return jdbc.sql("UPDATE magic_link_tokens SET consumed_at = :now" + + " WHERE token_hash = :tokenHash AND consumed_at IS NULL AND expires_at > :now" + + " RETURNING email") + .param("tokenHash", tokenHash) + .param("now", now.atOffset(ZoneOffset.UTC)) + .query(String.class) + .optional(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java b/src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java new file mode 100644 index 00000000..01174b6b --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/ApiAuthenticationEntryPoint.java @@ -0,0 +1,44 @@ +package org.patinanetwork.patchats.auth.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +/** + * Answers unauthenticated requests to protected endpoints with a 401 in the standard JSON envelope. + * + *

To protect an endpoint (i.e. make it trigger this entry point when no session cookie is presented), add a rule for + * it in {@link SecurityConfig}'s {@code authorizeHttpRequests} block: + * + *

{@code
+ * .authorizeHttpRequests(auth -> auth
+ *         .requestMatchers(HttpMethod.GET, "/api/matches/**").authenticated()  // members only
+ *         .anyRequest().permitAll())
+ * }
+ * + * The controller can then read the signed-in member via {@code @AuthenticationPrincipal AuthenticatedMember}. + */ +@Component +@RequiredArgsConstructor +public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + final HttpServletRequest request, + final HttpServletResponse response, + final AuthenticationException authException) + throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getWriter(), ApiResponder.failure("Not signed in")); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java b/src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java new file mode 100644 index 00000000..e26a820f --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/AuthenticatedMember.java @@ -0,0 +1,23 @@ +package org.patinanetwork.patchats.auth.security; + +import java.io.Serializable; +import java.security.Principal; +import java.util.UUID; +import org.patinanetwork.patchats.api.member.db.models.Member; + +/** + * The security principal stored in the session. Must stay {@link Serializable} (and small): Spring Session JDBC + * serializes the whole {@code SecurityContext} into {@code spring_session_attributes}. Implementing {@link Principal} + * gives {@code Authentication.getName()} — and Spring Session's {@code PRINCIPAL_NAME} index — the member's email. + */ +public record AuthenticatedMember(UUID memberId, String email) implements Principal, Serializable { + + public static AuthenticatedMember of(final Member member) { + return new AuthenticatedMember(member.getId(), member.getEmail()); + } + + @Override + public String getName() { + return email; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java new file mode 100644 index 00000000..9638a80f --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/SecurityConfig.java @@ -0,0 +1,119 @@ +package org.patinanetwork.patchats.auth.security; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.auth.AuthProperties; +import org.springframework.boot.autoconfigure.session.SessionProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.session.web.http.DefaultCookieSerializer; + +/** + * Wires cookie-session authentication on top of Spring Session JDBC. + * + *

How a request is authenticated. Spring Session's {@code SessionRepositoryFilter} resolves the + * {@code patchats_session} cookie into an {@code HttpSession} backed by the {@code spring_session} tables, and Spring + * Security's {@code SecurityContextHolderFilter} restores the {@link AuthenticatedMember} principal that + * {@code AuthController} saved at magic-link verification. There is no {@code JSESSIONID} and the server never adopts a + * client-supplied session id, so session fixation is prevented by construction (verify additionally rotates any + * pre-existing session id). + * + *

CSRF. Double-submit protection via the Spring-documented SPA pattern: {@code CookieCsrfTokenRepository} + * writes a JS-readable {@code XSRF-TOKEN} cookie on every response (see {@link SpaCsrfTokenRequestHandler}), and the + * frontend echoes it back as an {@code X-XSRF-TOKEN} header on state-changing requests. This is defense-in-depth on top + * of the {@code SameSite=Lax} session cookie and the JSON-only request bodies. Three anonymous endpoints are exempt — + * {@code POST} of {@code auth/request-link}, {@code auth/verify}, and {@code members} (sign-up). None of them ride a + * session (their only credential, if any, is in the body), and a first-time visitor has no {@code XSRF-TOKEN} cookie + * yet, so requiring the token would block real sign-ins and sign-ups while protecting nothing. Each exemption is pinned + * to {@code POST} so future authenticated verbs on those paths keep protection. + */ +@Configuration +@EnableConfigurationProperties({AuthProperties.class, SessionProperties.class}) +@RequiredArgsConstructor +public class SecurityConfig { + + public static final String SESSION_COOKIE_NAME = "patchats_session"; + + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + + /** Shapes the Spring Session cookie; picked up automatically by Spring Session's auto-configuration. */ + @Bean + public DefaultCookieSerializer cookieSerializer( + final AuthProperties authProperties, final SessionProperties sessionProperties) { + final DefaultCookieSerializer serializer = new DefaultCookieSerializer(); + serializer.setCookieName(SESSION_COOKIE_NAME); + serializer.setUseHttpOnlyCookie(true); + serializer.setUseSecureCookie(authProperties.isCookieSecure()); + serializer.setSameSite("Lax"); + serializer.setCookiePath("/"); + // Persistent cookie matching the server-side inactivity timeout (spring.session.timeout). + final Duration timeout = sessionProperties.getTimeout(); + serializer.setCookieMaxAge((int) timeout.toSeconds()); + return serializer; + } + + /** Shared by the filter chain (restore on request) and {@code AuthController} (save on login). */ + @Bean + public SecurityContextRepository securityContextRepository() { + return new HttpSessionSecurityContextRepository(); + } + + /** + * Default/production chain. + * + *

NOTE: the admin role is not yet assigned anywhere, so the email rule still fails closed — every caller is + * denied until an admin domain lands. Other endpoints keep their prior open posture. + */ + @Bean + @Profile("!dev") + SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { + return common(http) + .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**") + .hasRole("ADMIN") + .requestMatchers(HttpMethod.GET, "/api/session") + .authenticated() + .anyRequest() + .permitAll()) + .build(); + } + + /** Local-dev chain: only the session endpoint needs auth so the login flow can be exercised end to end. */ + @Bean + @Profile("dev") + SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Exception { + return common(http) + .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.GET, "/api/session") + .authenticated() + .anyRequest() + .permitAll()) + .build(); + } + + private HttpSecurity common(final HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) + .ignoringRequestMatchers( + PathPatternRequestMatcher.withDefaults() + .matcher(HttpMethod.POST, "/api/auth/request-link"), + PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/api/auth/verify"), + // Sign-up is anonymous: a first-time visitor has no XSRF-TOKEN cookie yet (the SPA is + // served by Vite in dev, so nothing has hit the backend), and there is no session to + // ride, so the token would block real sign-ups while protecting nothing. Scoped to + // POST only — the member domain's authenticated PATCH/DELETE must keep CSRF. + PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/api/members"))) + .requestCache(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .securityContext(context -> context.securityContextRepository(securityContextRepository())) + .exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint)); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/security/SpaCsrfTokenRequestHandler.java b/src/main/java/org/patinanetwork/patchats/auth/security/SpaCsrfTokenRequestHandler.java new file mode 100644 index 00000000..8b40b301 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/security/SpaCsrfTokenRequestHandler.java @@ -0,0 +1,41 @@ +package org.patinanetwork.patchats.auth.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.function.Supplier; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.util.StringUtils; + +/** + * The CSRF request handler for single-page apps, straight from the Spring Security reference. Two jobs: + * + *

    + *
  • {@link #handle} applies BREACH protection (XOR masking) to server-rendered token values and resolves the + * deferred token on every request, which makes {@code CookieCsrfTokenRepository} write the readable + * {@code XSRF-TOKEN} cookie the SPA echoes back. + *
  • {@link #resolveCsrfTokenValue} accepts the raw (unmasked) value when it arrives via the {@code X-XSRF-TOKEN} + * header — the SPA copies the cookie verbatim — while still unmasking values submitted as request parameters. + *
+ */ +final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + + private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle( + final HttpServletRequest request, final HttpServletResponse response, final Supplier csrfToken) { + this.xor.handle(request, response, csrfToken); + // Resolve the deferred token so the repository writes the XSRF-TOKEN cookie on this response. + csrfToken.get(); + } + + @Override + public String resolveCsrfTokenValue(final HttpServletRequest request, final CsrfToken csrfToken) { + final String headerValue = request.getHeader(csrfToken.getHeaderName()); + return (StringUtils.hasText(headerValue) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java new file mode 100644 index 00000000..83de3bc9 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.common.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Provides the single injectable {@link Clock} so services never call {@code Instant.now()} directly. */ +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java index 32ddab0a..323c8240 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -1,6 +1,8 @@ package org.patinanetwork.patchats.common.web; import java.util.stream.Collectors; +import org.patinanetwork.patchats.auth.InvalidMagicLinkException; +import org.patinanetwork.patchats.auth.TooManyLinkRequestsException; import org.patinanetwork.patchats.common.dto.ApiResponder; import org.patinanetwork.patchats.common.web.exception.EmailNotFoundException; import org.patinanetwork.patchats.common.web.exception.EmailNotResendableException; @@ -72,6 +74,16 @@ public ResponseEntity> handleValidation(ValidationException e return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); } + @ExceptionHandler(InvalidMagicLinkException.class) + public ResponseEntity> handleInvalidMagicLink(final InvalidMagicLinkException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(TooManyLinkRequestsException.class) + public ResponseEntity> handleTooManyLinkRequests(final TooManyLinkRequestsException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ApiResponder.failure(ex.getMessage())); + } + private String formatError(final FieldError error) { return error.getField() + " " + error.getDefaultMessage(); } diff --git a/src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java b/src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java new file mode 100644 index 00000000..5be54c6f --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/AuthControllerTest.java @@ -0,0 +1,106 @@ +package org.patinanetwork.patchats.auth; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AuthController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(ApiExceptionHandler.class) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthService authService; + + @MockitoBean + private MemberRepo members; + + @MockitoBean + private SecurityContextRepository securityContextRepository; + + @Test + void requestLinkAlwaysReturnsGenericMessage() throws Exception { + mockMvc.perform(post("/api/auth/request-link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"ann@example.com\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Check your email for a sign-in link.")); + + verify(authService).requestLink(eq("ann@example.com"), anyString()); + } + + @Test + void requestLinkRejectsMalformedEmail() throws Exception { + mockMvc.perform(post("/api/auth/request-link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"not-an-email\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void verifyReturnsSessionPayloadAndSavesContext() throws Exception { + final Member member = Member.builder() + .id(UUID.randomUUID()) + .email("ann@example.com") + .firstName("Ann") + .lastName("Example") + .build(); + when(authService.verify("raw-token")).thenReturn(member); + + mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.email").value("ann@example.com")) + .andExpect(jsonPath("$.payload.name").value("Ann Example")) + .andExpect(jsonPath("$.payload.isAdmin").value(false)); + + verify(securityContextRepository).saveContext(any(), any(), any()); + } + + @Test + void verifyMapsInvalidTokenToBadRequestEnvelope() throws Exception { + when(authService.verify("spent")).thenThrow(new InvalidMagicLinkException()); + + mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"spent\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.message").value("This sign-in link is invalid or has expired. Request a new one.")); + } + + @Test + void logoutIsIdempotent() throws Exception { + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Signed out.")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java new file mode 100644 index 00000000..638c7138 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java @@ -0,0 +1,124 @@ +package org.patinanetwork.patchats.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository; + +class AuthServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-03T12:00:00Z"); + + private final MagicLinkTokenRepository tokens = mock(MagicLinkTokenRepository.class); + private final MemberRepo members = mock(MemberRepo.class); + private final MagicLinkEmailComposer emailComposer = mock(MagicLinkEmailComposer.class); + private final RequestLinkRateLimiter rateLimiter = mock(RequestLinkRateLimiter.class); + private final AuthProperties properties = new AuthProperties(); + + private AuthService authService; + + @BeforeEach + void setUp() { + properties.setBaseUrl("http://localhost:5173"); + authService = new AuthService( + tokens, + members, + new TokenGenerator(), + emailComposer, + rateLimiter, + properties, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void requestLinkNormalizesEmailAndStoresHashNotRaw() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(true); + when(members.getMemberByEmail("ann@example.com")).thenReturn(Optional.of(member("ann@example.com"))); + + authService.requestLink(" Ann@Example.COM ", "10.0.0.1"); + + verify(tokens).deleteByEmail("ann@example.com"); + final ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor expiry = ArgumentCaptor.forClass(Instant.class); + verify(tokens).insertToken(any(UUID.class), eq("ann@example.com"), hash.capture(), expiry.capture()); + final ArgumentCaptor raw = ArgumentCaptor.forClass(String.class); + verify(emailComposer).send(eq("ann@example.com"), raw.capture()); + + // The emailed value and the stored value must differ, and the stored one is the SHA-256 of the raw. + assertNotEquals(raw.getValue(), hash.getValue()); + assertEquals(TokenGenerator.hash(raw.getValue()), hash.getValue()); + assertEquals(NOW.plus(properties.getMagicLinkTtl()), expiry.getValue()); + } + + @Test + void requestLinkSurfacesRateLimitAs429() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(false); + + assertThrows(TooManyLinkRequestsException.class, () -> authService.requestLink("ann@example.com", "10.0.0.1")); + + verifyNoInteractions(tokens, emailComposer); + } + + @Test + void requestLinkSkipsUnregisteredEmailSilently() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(true); + when(members.getMemberByEmail("stranger@example.com")).thenReturn(Optional.empty()); + + authService.requestLink("stranger@example.com", "10.0.0.1"); + + verifyNoInteractions(tokens, emailComposer); + } + + @Test + void verifyRejectsUnknownOrSpentToken() { + when(tokens.consumeAndReturnEmail(anyString(), any())).thenReturn(Optional.empty()); + + assertThrows(InvalidMagicLinkException.class, () -> authService.verify("bogus")); + } + + @Test + void verifyReturnsTheMemberBehindTheToken() { + final Member existing = member("ann@example.com"); + when(tokens.consumeAndReturnEmail(TokenGenerator.hash("raw-token"), NOW)) + .thenReturn(Optional.of("ann@example.com")); + when(members.getMemberByEmail("ann@example.com")).thenReturn(Optional.of(existing)); + + assertEquals(existing, authService.verify("raw-token")); + } + + @Test + void verifyRejectsTokenWhoseMemberNoLongerExists() { + when(tokens.consumeAndReturnEmail(TokenGenerator.hash("raw-token"), NOW)) + .thenReturn(Optional.of("gone@example.com")); + when(members.getMemberByEmail("gone@example.com")).thenReturn(Optional.empty()); + + assertThrows(InvalidMagicLinkException.class, () -> authService.verify("raw-token")); + } + + private static Member member(final String email) { + return Member.builder() + .id(UUID.randomUUID()) + .email(email) + .firstName("Ann") + .lastName("Example") + .build(); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java b/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java new file mode 100644 index 00000000..b36432ec --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java @@ -0,0 +1,42 @@ +package org.patinanetwork.patchats.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.email.OutgoingEmail; +import org.patinanetwork.patchats.email.TemplateRenderer; + +class MagicLinkEmailComposerTest { + + private final AtomicReference captured = new AtomicReference<>(); + private final AuthProperties properties = new AuthProperties(); + private final MagicLinkEmailComposer composer = + new MagicLinkEmailComposer(new TemplateRenderer(), captured::set, properties); + + @Test + void bodyContainsVerifyLinkAndTtl() { + properties.setBaseUrl("https://patchats.example.org"); + + composer.send("ann@example.com", "raw-token-123"); + + final OutgoingEmail email = captured.get(); + assertNotNull(email); + assertEquals(List.of("ann@example.com"), email.to()); + assertEquals("Your PatChats sign-in link", email.subject()); + assertTrue(email.body().contains("https://patchats.example.org/auth/verify?token=raw-token-123")); + assertTrue(email.body().contains("expires in 15 minutes")); + } + + @Test + void trailingSlashInBaseUrlDoesNotDoubleUp() { + properties.setBaseUrl("http://localhost:5173/"); + + composer.send("ann@example.com", "tok"); + + assertTrue(captured.get().body().contains("http://localhost:5173/auth/verify?token=tok")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java new file mode 100644 index 00000000..4395fe5b --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java @@ -0,0 +1,49 @@ +package org.patinanetwork.patchats.auth; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RequestLinkRateLimiterTest { + + private final RequestLinkRateLimiter rateLimiter = new RequestLinkRateLimiter(); + + @Test + void allowsThreeRequestsPerEmailThenDenies() { + for (int i = 0; i < 3; i++) { + assertTrue(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"), "request " + (i + 1)); + } + assertFalse(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1")); + } + + @Test + void emailBudgetsAreIndependent() { + for (int i = 0; i < 3; i++) { + rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"); + } + assertTrue(rateLimiter.tryAcquire("bob@example.com", "10.0.0.2")); + } + + @Test + void capsRequestsPerIpAcrossEmails() { + for (int i = 0; i < 10; i++) { + assertTrue(rateLimiter.tryAcquire("user" + i + "@example.com", "10.0.0.9"), "request " + (i + 1)); + } + assertFalse(rateLimiter.tryAcquire("user10@example.com", "10.0.0.9")); + } + + @Test + void ipDenialDoesNotBurnTheEmailBudget() { + // Exhaust the IP budget using other emails. + for (int i = 0; i < 10; i++) { + rateLimiter.tryAcquire("user" + i + "@example.com", "10.0.0.9"); + } + // Denied by IP — but ann's email budget must be untouched... + assertFalse(rateLimiter.tryAcquire("ann@example.com", "10.0.0.9")); + // ...so all 3 of her requests still succeed from a fresh IP. + for (int i = 0; i < 3; i++) { + assertTrue(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"), "request " + (i + 1)); + } + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java new file mode 100644 index 00000000..f68a6d29 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java @@ -0,0 +1,42 @@ +package org.patinanetwork.patchats.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken; + +class TokenGeneratorTest { + + private final TokenGenerator generator = new TokenGenerator(); + + @Test + void rawTokenIsUrlSafeAnd256Bits() { + final GeneratedToken token = generator.generate(); + + // 32 bytes base64url without padding -> 43 chars, no characters needing URL encoding. + assertEquals(43, token.raw().length()); + assertTrue(token.raw().matches("[A-Za-z0-9_-]+")); + } + + @Test + void hashMatchesSha256HexOfRaw() { + final GeneratedToken token = generator.generate(); + + assertEquals(TokenGenerator.hash(token.raw()), token.hash()); + assertEquals(64, token.hash().length()); + assertTrue(token.hash().matches("[0-9a-f]+")); + } + + @Test + void generatedTokensAreUnique() { + assertNotEquals(generator.generate().raw(), generator.generate().raw()); + } + + @Test + void hashIsDeterministic() { + assertEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abc")); + assertNotEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abd")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java b/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java new file mode 100644 index 00000000..d53dbc4d --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/security/SecurityWiringTest.java @@ -0,0 +1,140 @@ +package org.patinanetwork.patchats.auth.security; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.auth.AuthController; +import org.patinanetwork.patchats.auth.AuthService; +import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Exercises the default (non-dev) filter chain end to end with filters ON: anonymous rejection, programmatic login at + * verify, session-carried authentication, and logout. Runs without Spring Session's JDBC store — the servlet mock + * session stands in for it, which keeps the slice database-free while still proving the Spring Security wiring. + */ +@WebMvcTest(AuthController.class) +@Import({SecurityConfig.class, ApiAuthenticationEntryPoint.class, ApiExceptionHandler.class}) +class SecurityWiringTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthService authService; + + @MockitoBean + private MemberRepo members; + + @Test + void sessionEndpointRejectsAnonymousWithJsonEnvelope() throws Exception { + mockMvc.perform(get("/api/session")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("Not signed in")); + } + + @Test + void requestLinkIsReachableAnonymously() throws Exception { + mockMvc.perform(post("/api/auth/request-link") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"ann@example.com\"}")) + .andExpect(status().isOk()); + } + + @Test + void emailEndpointStaysAdminOnlyAndFailsClosed() throws Exception { + mockMvc.perform(post("/api/email/send") + .contentType(MediaType.APPLICATION_JSON) + .content("{}") + .with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + void stateChangingPostWithoutCsrfTokenIsForbidden() throws Exception { + // Logout is not in the CSRF-exempt set: it consumes the session cookie, so it needs the token. + mockMvc.perform(post("/api/auth/logout")).andExpect(status().isForbidden()); + } + + @Test + void anonymousSignUpIsExemptFromCsrf() throws Exception { + // A first-time visitor POSTing the sign-up form has no XSRF-TOKEN cookie yet, so requiring the token would + // make sign-up impossible. MemberController is outside this slice, so the concrete status is incidental — + // what matters is that CSRF did not reject it. See the exemption list in SecurityConfig. + mockMvc.perform(post("/api/members") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(result -> assertNotEquals( + HttpServletResponse.SC_FORBIDDEN, + result.getResponse().getStatus(), + "anonymous sign-up must not be blocked by CSRF")); + } + + @Test + void verifyEstablishesASessionThatAuthenticatesLaterRequests() throws Exception { + final Member member = Member.builder() + .id(UUID.randomUUID()) + .email("ann@example.com") + .firstName("Ann") + .lastName("Example") + .build(); + when(authService.verify("raw-token")).thenReturn(member); + when(members.getMemberByEmail(member.getEmail())).thenReturn(Optional.of(member)); + + final MvcResult login = mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andExpect(status().isOk()) + .andReturn(); + final MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); + assertNotNull(session, "verify must establish a session"); + + mockMvc.perform(get("/api/session").session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.email").value("ann@example.com")) + .andExpect(jsonPath("$.payload.name").value("Ann Example")); + } + + @Test + void logoutInvalidatesTheSession() throws Exception { + final Member member = Member.builder() + .id(UUID.randomUUID()) + .email("ann@example.com") + .firstName("Ann") + .lastName("Example") + .build(); + when(authService.verify("raw-token")).thenReturn(member); + + final MvcResult login = mockMvc.perform(post("/api/auth/verify") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"raw-token\"}")) + .andReturn(); + final MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false); + assertNotNull(session); + + mockMvc.perform(post("/api/auth/logout").session(session).with(csrf())).andExpect(status().isOk()); + assertTrue(session.isInvalid()); + } +} From 9abfdc20e3013601daed6e9269689cd243d27e55 Mon Sep 17 00:00:00 2001 From: Randy Dean Date: Mon, 17 Aug 2026 18:30:42 -0400 Subject: [PATCH 2/2] Temp --- .example.env | 9 +++- docs/auth-feature.md | 53 ++++++++++--------- docs/email-async/01-async-send-pipeline.md | 21 +++++--- js/src/features/auth/Login.page.test.tsx | 34 +++++++++++- js/src/features/auth/Login.page.tsx | 41 ++++++++++++-- js/src/features/auth/api/auth.mock.ts | 11 +++- js/src/features/auth/api/useLogout.ts | 4 +- js/src/features/auth/api/useRequestLink.ts | 3 +- js/src/features/auth/api/useSession.ts | 4 +- .../features/auth/api/useVerifyMagicLink.ts | 8 +-- js/src/features/members/api/useMembers.ts | 10 +--- .../features/sample/api/useSampleMessage.ts | 5 +- js/src/lib/api/client.ts | 24 +++------ js/src/lib/api/useSession.ts | 39 -------------- pom.xml | 13 +++++ .../patchats/PatChatsApplication.java | 2 + .../api/auth/security/SecurityConfig.java | 41 -------------- .../patchats/auth/AuthController.java | 2 +- .../patchats/auth/AuthService.java | 43 +++++++-------- .../auth/UnregisteredEmailException.java | 17 ++++++ .../patchats/auth/dto/SessionResponse.java | 2 +- ...epository.java => MagicLinkTokenRepo.java} | 17 ++---- .../auth/security/SecurityConfig.java | 2 + .../common/web/ApiExceptionHandler.java | 6 +++ .../patchats/utilities/EmailNormalizer.java | 20 +++++++ src/main/resources/application-dev.yml | 5 ++ src/main/resources/application.yml | 15 +++++- .../patchats/auth/AuthControllerTest.java | 17 +++++- .../patchats/auth/AuthServiceTest.java | 13 ++--- .../utilities/EmailNormalizerTest.java | 31 +++++++++++ 30 files changed, 309 insertions(+), 203 deletions(-) delete mode 100644 js/src/lib/api/useSession.ts delete mode 100644 src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/UnregisteredEmailException.java rename src/main/java/org/patinanetwork/patchats/auth/repo/{MagicLinkTokenRepository.java => MagicLinkTokenRepo.java} (71%) create mode 100644 src/main/java/org/patinanetwork/patchats/utilities/EmailNormalizer.java create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/test/java/org/patinanetwork/patchats/utilities/EmailNormalizerTest.java 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 fd4442d2..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. @@ -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 @@ -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); @@ -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 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/js/src/features/auth/api/useLogout.ts b/js/src/features/auth/api/useLogout.ts index cb4a2390..009c4f82 100644 --- a/js/src/features/auth/api/useLogout.ts +++ b/js/src/features/auth/api/useLogout.ts @@ -1,5 +1,6 @@ import { sessionQueryKey } from "@/features/auth/api/useSession"; import { apiFetch } from "@/lib/api/client"; +import { ApiResponder } from "@/lib/api/client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; @@ -9,7 +10,8 @@ export function useLogout() { const navigate = useNavigate(); return useMutation({ - mutationFn: () => apiFetch("/auth/logout", { method: "POST" }), + mutationFn: () => + apiFetch>("/auth/logout", { method: "POST" }), onSuccess: () => { queryClient.setQueryData(sessionQueryKey, null); navigate("/"); diff --git a/js/src/features/auth/api/useRequestLink.ts b/js/src/features/auth/api/useRequestLink.ts index eaa5b8bd..8526238c 100644 --- a/js/src/features/auth/api/useRequestLink.ts +++ b/js/src/features/auth/api/useRequestLink.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/api/client"; +import { ApiResponder } from "@/lib/api/client"; import { useMutation } from "@tanstack/react-query"; /** @@ -8,7 +9,7 @@ import { useMutation } from "@tanstack/react-query"; export function useRequestLink() { return useMutation({ mutationFn: (email: string) => - apiFetch("/auth/request-link", { + apiFetch>("/auth/request-link", { method: "POST", body: JSON.stringify({ email }), }), diff --git a/js/src/features/auth/api/useSession.ts b/js/src/features/auth/api/useSession.ts index 07c52c2a..6b29dbeb 100644 --- a/js/src/features/auth/api/useSession.ts +++ b/js/src/features/auth/api/useSession.ts @@ -1,4 +1,4 @@ -import { ApiError, apiFetch } from "@/lib/api/client"; +import { ApiError, apiFetch, ApiResponder } from "@/lib/api/client"; import { useQuery } from "@tanstack/react-query"; /** @@ -23,7 +23,7 @@ export function useSession() { queryKey: sessionQueryKey, queryFn: async () => { try { - return await apiFetch("/session"); + return (await apiFetch>("/session")).payload; } catch (error) { if (error instanceof ApiError && error.status === 401) { return null; diff --git a/js/src/features/auth/api/useVerifyMagicLink.ts b/js/src/features/auth/api/useVerifyMagicLink.ts index fe390071..9de44436 100644 --- a/js/src/features/auth/api/useVerifyMagicLink.ts +++ b/js/src/features/auth/api/useVerifyMagicLink.ts @@ -1,5 +1,5 @@ import { Session, sessionQueryKey } from "@/features/auth/api/useSession"; -import { apiFetch } from "@/lib/api/client"; +import { apiFetch, ApiResponder } from "@/lib/api/client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; /** @@ -19,12 +19,12 @@ export function useVerifyMagicLink(token: string | null) { return useQuery({ queryKey: ["auth", "verify", token], queryFn: async () => { - const session = await apiFetch("/auth/verify", { + const session = await apiFetch>("/auth/verify", { method: "POST", body: JSON.stringify({ token }), }); - queryClient.setQueryData(sessionQueryKey, session); - return session; + queryClient.setQueryData(sessionQueryKey, session.payload); + return session.payload; }, enabled: token !== null, retry: false, diff --git a/js/src/features/members/api/useMembers.ts b/js/src/features/members/api/useMembers.ts index 2d95d4ab..de9e64e1 100644 --- a/js/src/features/members/api/useMembers.ts +++ b/js/src/features/members/api/useMembers.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api/client"; +import { apiFetch, ApiResponder } from "@/lib/api/client"; import { useQuery } from "@tanstack/react-query"; export interface Member { @@ -19,12 +19,6 @@ export interface Member { updatedAt: string; } -interface MembersResponse { - message: string; - payload: Member[]; - success: boolean; -} - export interface MemberFilters { active?: string; email?: string; @@ -54,7 +48,7 @@ export function useMembers(filters: MemberFilters = {}) { const query = searchParams.toString(); const path = query ? `/members?${query}` : "/members"; - const response = await apiFetch(path); + const response = await apiFetch>(path); return response.payload; }, }); diff --git a/js/src/features/sample/api/useSampleMessage.ts b/js/src/features/sample/api/useSampleMessage.ts index 7fc53d45..c396b121 100644 --- a/js/src/features/sample/api/useSampleMessage.ts +++ b/js/src/features/sample/api/useSampleMessage.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "@/lib/api/client"; +import { apiFetch, ApiResponder } from "@/lib/api/client"; import { useQuery } from "@tanstack/react-query"; export interface SampleMessage { @@ -11,6 +11,7 @@ export const sampleMessageQueryKey = ["sample", "message"] as const; export function useSampleMessage() { return useQuery({ queryKey: sampleMessageQueryKey, - queryFn: () => apiFetch("/sample/message"), + queryFn: async () => + (await apiFetch>("/sample/message")).payload, }); } diff --git a/js/src/lib/api/client.ts b/js/src/lib/api/client.ts index 98fa5c11..bfa04764 100644 --- a/js/src/lib/api/client.ts +++ b/js/src/lib/api/client.ts @@ -22,18 +22,13 @@ export class ApiError extends Error { constructor( public readonly status: number, message: string, + public readonly body: unknown, ) { super(message); this.name = "ApiError"; } } -interface ApiEnvelope { - success: boolean; - message?: string; - payload?: T; -} - function readCookie(name: string): string | undefined { return document.cookie .split("; ") @@ -63,17 +58,12 @@ export async function apiFetch( ...init?.headers, }, }); - - const envelope = (await response.json().catch(() => undefined)) as - | ApiEnvelope - | undefined; - if (!response.ok) { - throw new ApiError( - response.status, - envelope?.message ?? `Request to ${path} failed`, - ); + const body = await response.json().catch(() => undefined); + const message = + (body as ApiResponder | undefined)?.message ?? + `Request to ${path} failed`; + throw new ApiError(response.status, message, body); } - - return envelope?.payload as T; + return response.json() as Promise; } diff --git a/js/src/lib/api/useSession.ts b/js/src/lib/api/useSession.ts deleted file mode 100644 index bcbcfc62..00000000 --- a/js/src/lib/api/useSession.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { apiFetch } from "@/lib/api/client"; -import { useQuery } from "@tanstack/react-query"; - -/** - * The current authenticated user. - * - * PLACEHOLDER: this lives in `lib/api` so the route guards have a session source - * today. Once auth endpoints are wired, move this into a real `features/auth/api/` - * and import it from there. - */ -export interface Session { - id: string; - name: string; - isAdmin: boolean; -} - -export const sessionQueryKey = ["session"] as const; - -export function useSession() { - return useQuery({ - queryKey: sessionQueryKey, - queryFn: () => { - // Authentication is not wired yet. Local development uses an admin - // identity so guarded pages can be exercised; production still fails - // closed when the session endpoint is unavailable. - if (import.meta.env.DEV) { - return Promise.resolve({ - id: "local-dev-admin", - isAdmin: true, - name: "Local admin", - }); - } - - return apiFetch("/session"); - }, - retry: false, - staleTime: Infinity, - }); -} 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 +