Skip to content
Draft

Temp #84

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=codebloom
DATABASE_NAME=patchats
DATABASE_USER=postgres
DATABASE_PASSWORD=enterpasswordhere
# With the example values, this gets combined inside of the application.properties to make
# jdbc://postgresql://localhost:5432/codebloom?user=postgres&password=enterpasswordhere
# jdbc://postgresql://localhost:5432/patchats?user=postgres&password=enterpasswordhere

# SMTP — consumed by spring.mail.* in non-dev profiles (the dev profile logs instead of sending)
SMTP_HOST=smtp.example.com
Expand All @@ -16,3 +16,8 @@ SMTP_PASSWORD=enterpasswordhere
# The verified From sender (a real, monitored mailbox on a domain you control)
EMAIL_FROM=coffeechats@patinanetwork.org
EMAIL_FROM_NAME=PatChats

# Auth — public origin of the SPA; magic links point at $APP_BASE_URL/auth/verify?token=...
APP_BASE_URL=http://localhost:5173
# Set to false only when serving over plain HTTP (the dev profile already does this)
AUTH_COOKIE_SECURE=true
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
59 changes: 34 additions & 25 deletions docs/auth-feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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
`<app.auth.base-url>/auth/verify?token=<raw>`. 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
Expand All @@ -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`
Expand All @@ -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`).
34 changes: 33 additions & 1 deletion js/src/features/auth/Login.page.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(<LoginPage />);

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(<LoginPage />);
Expand Down
41 changes: 37 additions & 4 deletions js/src/features/auth/Login.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -28,18 +29,25 @@ 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 (
<Stack gap="md">
<Title order={2}>Check your email</Title>
<Text>
If you entered a valid address, a sign-in link is on its way to{" "}
<Text component="span" fw={700}>
{form.getValues().email.trim()}
{submittedEmail}
</Text>
. The link expires in 15 minutes and can only be used once.
</Text>
Expand All @@ -60,6 +68,31 @@ export default function LoginPage() {
);
}

if (isUnregistered) {
return (
<Stack gap="md">
<Title order={2}>No account for that email</Title>
<Text>
We couldn&apos;t find a PatChats account for{" "}
<Text component="span" fw={700}>
{submittedEmail}
</Text>
. Sign-in links are only sent to registered members.
</Text>
<Button onClick={() => requestLink.reset()}>
Try a different email
</Button>
<Text c="dimmed" size="sm">
Never signed up?{" "}
<Anchor component={Link} to="/sign-up" inherit>
Complete the sign-up form
</Anchor>{" "}
to join.
</Text>
</Stack>
);
}

return (
<Paper p="lg" withBorder>
<form onSubmit={handleSubmit} noValidate>
Expand Down
11 changes: 10 additions & 1 deletion js/src/features/auth/api/auth.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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(
{
Expand Down
13 changes: 13 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- <dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableAsync
@EnableScheduling
@Slf4j
public class PatChatsApplication {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
@RequiredArgsConstructor
public class AuthController {

/** The response is identical whether or not the email has an account, so nothing can be enumerated. */
/** Success copy for a link that was actually sent; an unregistered email fails with 404 instead. */
private static final String GENERIC_REQUEST_MESSAGE = "Check your email for a sign-in link.";

private final AuthService authService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,11 @@ public class AuthProperties {

/** How long an emailed magic link stays valid. */
private Duration magicLinkTtl = Duration.ofMinutes(15);

/**
* How often expired magic-link rows are swept from the database. Declared here so the key is documented and shows
* up in config metadata; {@link MagicLinkTokenCleanup} resolves it as a {@code @Scheduled} placeholder, which
* cannot read a bound bean.
*/
private Duration tokenCleanupInterval = Duration.ofHours(1);
}
Loading