diff --git a/.example.env b/.example.env index dbdd07c..fd1feb5 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/Justfile b/Justfile index b29dda3..754059b 100644 --- a/Justfile +++ b/Justfile @@ -12,6 +12,10 @@ drop *args: backend-dev *args: dotenvx run -f .env -- ./mvnw -Dspring-boot.run.profiles=dev spring-boot:run {{args}} +# Run the backend Spring server with real SMTP delivery instead of the logging email sender +backend-smtp *args: + dotenvx run -f .env -- ./mvnw -Dspring-boot.run.profiles=smtp spring-boot:run {{args}} + # Run the backend Spring server with an exposed debugger at :5005 backend-dev-debug *args: dotenvx run -- ./mvnw \ diff --git a/docs/auth-feature.md b/docs/auth-feature.md index 5d664e2..58944a8 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 @@ -20,10 +18,10 @@ src/main/java/org/patinanetwork/patchats/auth/ 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 + MagicLinkTokenCleanup.java @Scheduled sweep of expired token rows 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 +38,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 @@ -56,6 +57,10 @@ js/src/features/auth/ (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. + Magic-link rows get the same treatment from `MagicLinkTokenCleanup`, a `@Scheduled` sweep that + deletes anything past `expires_at` every `app.auth.token-cleanup-interval` — nothing else ever + deletes them, which is also why the table needs no index beyond the `token_hash` unique + constraint that `verify` looks up on. `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` @@ -77,30 +82,34 @@ 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 | +| `app.auth.token-cleanup-interval` | — | `1h` | How often expired token rows are swept | +| `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/js/src/features/auth/Login.page.test.tsx b/js/src/features/auth/Login.page.test.tsx index eacbd55..cbb29a9 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 e25e29b..d4224d6 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 11bea50..7c5e33f 100644 --- a/js/src/features/auth/api/auth.mock.ts +++ b/js/src/features/auth/api/auth.mock.ts @@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw"; /** * MSW handlers for the auth domain, envelope-shaped like the real backend. - * Defaults: request-link succeeds generically, verify signs in a member, and + * Defaults: request-link succeeds, verify signs in a member, and * there is no session (401). Tests override per case with `server.use(...)` * and the exported fixtures. */ @@ -25,6 +25,15 @@ export const invalidLinkResponse = () => { status: 400 }, ); +export const unregisteredEmailResponse = () => + HttpResponse.json( + { + success: false, + message: "We couldn't find an account for that email.", + }, + { status: 404 }, + ); + export const rateLimitedResponse = () => HttpResponse.json( { diff --git a/pom.xml b/pom.xml index 4d23ebe..91ec697 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 +