-
Notifications
You must be signed in to change notification settings - Fork 0
Auth: document the magic-link feature #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
RandyJDean
wants to merge
1
commit into
07-09-auth_frontend_login_verify_session_and_logout
Choose a base branch
from
07-09-auth_document_the_magic-link_feature
base: 07-09-auth_frontend_login_verify_session_and_logout
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| # 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. | ||
|
|
||
| **Unified signup/login.** Requesting a link never reveals whether an account exists (the response is | ||
| always the same). If a link is verified for an unknown email, a minimal **shell** member row is | ||
| created (`full_name` null, `profile_completed_at` null) and the frontend routes the user to the | ||
| sign-up form to complete their profile. Wiring the sign-up form submission to the backend (and | ||
| setting `profile_completed_at`) is a separate ticket; until then shell members stay on `/sign-up`. | ||
|
|
||
| ## The shape | ||
|
|
||
| ``` | ||
| src/main/java/org/patinanetwork/patchats/auth/ | ||
| AuthController.java POST /api/auth/request-link | verify | logout, GET /api/session | ||
| AuthService.java request-link + verify orchestration | ||
| TokenGenerator.java SecureRandom 256-bit raw token + SHA-256 hex digest | ||
| MagicLinkEmailComposer.java builds the sign-in email via the EmailSender PORT | ||
| RequestLinkRateLimiter.java Bucket4j: 3/email + 10/IP per 15 min, in-memory buckets | ||
| AuthProperties.java @ConfigurationProperties("app.auth") → base-url, cookie-secure, magic-link-ttl | ||
| repo/ | ||
| MagicLinkTokenRepository.java JdbcClient; atomic UPDATE..RETURNING consume | ||
| MemberAccountRepository.java auth's minimal view of members (find, race-safe shell insert) | ||
| 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, rate-limits silently | ||
| (response is always the generic 200), deletes outstanding tokens for that email, 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`). | ||
| 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 or | ||
| shell-creates the member, 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, profileCompleted }` — `name` null and `profileCompleted` false for | ||
| shell accounts; 401 in the envelope when signed out. The frontend `RequireAuth` guard sends | ||
| signed-out visitors to `/login` and incomplete profiles to `/sign-up`. | ||
|
|
||
| Why CSRF protection is off, and why that is safe here, is documented on `SecurityConfig` — keep that | ||
| javadoc current if the cookie or CORS posture ever changes. | ||
|
|
||
| ## 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. Open `http://localhost:5173/login`, submit your email. | ||
| 2. 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. | ||
| 3. Open it: a new email lands on `/sign-up` (shell account); an existing completed member lands on | ||
| `/`. Check DevTools → Application → Cookies for `patchats_session` (httpOnly, Lax, not Secure in | ||
| dev). | ||
| 4. 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 still shows the generic panel but | ||
| sends nothing (rate limit, logged as a warning). | ||
| 5. Log out from the header; 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/V0004`–`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`). | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like the design of this signup flow is pretty focused on avoiding revealing whether or not the email is in the system, which it seems is why we have a silent rate limit?
That's fine to target but seems to lead to bad UX for legitimate users here because users also won't know if they are rate limited. This may actually lead to more attempts.
What if we rate limited (with a 429) for all emails, including non-registered ones? There will still be no distinction between registered and non-registered emails. Does that make sense?