Feature Changes (detailed in description) - #168
Open
dhyantsoni wants to merge 10 commits into
Open
Conversation
New security-driven self-service reset: a student proves account ownership by signing in with their @stu.powayusd.com school Google account, and the trailing 5 digits of that email (format name+lastinitial+5digits) must match the last 5 digits of the account's stored sid before a reset is allowed. GoogleIdTokenVerifier: verifies the Google Identity Services ID token server-side via Google's tokeninfo endpoint (checks aud, iss, email_verified). The existing signup flow only ever decoded this token client-side and never verified it -- fine for a UX nicety, not acceptable as an actual security gate, so this reset flow re-verifies independently. POST /mvc/person/reset/oauth/verify: reuses the admin/default-account guards and rate limiting from the existing email-based /reset/start flow, verifies the ID token, requires the school-email digit match against sid, and on success issues a single-use token via the existing ResetCode infrastructure (HMAC-signed, 5-minute TTL, rate-limited -- same mechanism the email flow uses, just returned directly instead of emailed, since identity is already proven via the verified token). All failure paths return an identical generic 403 regardless of which check failed, so the endpoint can't be used to enumerate valid uid/sid pairs. POST /mvc/person/reset/oauth/complete: consumes the token, enforces an 8-character minimum password (Spring's account-creation endpoint has no equivalent server-side check -- this one does), updates the BCrypt-encoded password, and best-effort syncs the new password to Flask via FlaskPasswordSync so both backends stay in sync for the same account. FlaskPasswordSync: calls Flask's new internal sync endpoint (POST /api/internal/sync-password) with a shared secret (INTERNAL_SYNC_KEY). Best-effort -- a sync failure is logged, not fatal to the already-successful Spring-side reset. MvcSecurityConfig: permitAll matchers for both new endpoints, following the existing convention next to /reset/start and /reset/check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…y reset link Lets a user who hits the OAuth password-reset rate limit raise a ResetTicket instead of waiting out the window; an admin resolves it from the person/read portal, granting a batch of 5 extra reset attempts (ResetCode.grantBonusAttempts). Also fixes two issues found while auditing the reset flow: - ResetCode resolved RESET_TOKEN_SECRET via System.getenv() only, which never sees values from Spring's .env import, so it silently signed tokens with a random per-restart key. Now resolves through Dotenv like the rest of the reset code does, and fails closed instead of falling back to an ephemeral key. - login.html's "Forgot Password?" link pointed at the old email-code reset flow instead of the newer OAuth + student ID verified flow on the pages site.
…equest Review feedback flagged that /mvc/person/reset/ticket is unauthenticated and takes an arbitrary uid, so its per-uid idempotency check doesn't stop someone paging through many different uids to spam the admin ticket queue. Added a 5-requests-per-15-minutes-per-IP limit (ResetCode.canRequestTicket), separate from the global RateLimitFilter which is tuned for gross abuse, not this pattern. While testing that fix through the real endpoint (previous testing had only ever inserted ticket rows via direct SQL), found every creation request was actually failing with a 500: ResetTicket's GenerationType.AUTO resolves to sequence-table ID generation on this SQLite dialect, and no such sequence table exists under ddl-auto=none. Switched to GenerationType.IDENTITY, matching the convention every other SQLite-backed entity here already uses.
…-TLS URI The Spring->Flask sync call sends the new password in plaintext, protected only by a shared secret header, not encryption. Reviewing the actual deployment topology (nginx configs in both repos front the same public IP and proxy to localhost, both READMEs describe deploying through the same "cockpit") strongly suggests this is same-host loopback traffic today, but no tracked config in either repo actually pins FLASK_URI to that -- if anyone ever points it at the public hostname instead, this call would send a plaintext password over a real network hop with no TLS enforced. Adds a transport check before the sync call: allow loopback (localhost/ 127.0.0.1, any scheme) or any https:// URI, otherwise skip the sync and log why. Parses the actual host via java.net.URI rather than string-prefix matching, so a lookalike like http://localhost.attacker.com can't slip past a naive startsWith("http://localhost") check.
*** REQUIRED BEFORE THIS BRANCH DEPLOYS: two schema changes in this branch
have only been applied to the local dev SQLite DB, not production:
ALTER TABLE person ADD COLUMN token_version bigint NOT NULL DEFAULT 0;
That's this commit's dependency. The earlier commit on this branch,
4e13df6 ("Rate-limit reset-ticket creation and fix a silent 500 on every
real request"), also needs its reset_ticket table created in production --
see that commit's message, or forgot-password-pipeline.md at the prodsys
repo root, for the exact CREATE TABLE. Until both run against production,
logins and reset-ticket usage there will error on the missing schema.
***
Spring's JWT carried only sub + roles, nothing password-derived, and
validateToken only checked username match + the static 12h expiry -- no
server-side revocation existed. A stolen JWT kept working for the rest of
its lifetime even after the account's password was reset, which defeats the
point of a reset triggered because compromise is suspected.
Adds Person.tokenVersion, bumped in PersonDetailsService.save() whenever
samePassword is false -- the single funnel every password-change path
(OAuth reset, email-code reset, admin reset, self-service profile update)
already goes through. generateToken now stamps the token with the person's
current tokenVersion; validateToken rejects if it doesn't match the current
DB value.
Verified live: fresh JWT -> 200 on a protected /api/** route, admin-reset
the account's password -> same old JWT now 401, fresh login -> 200 again.
Scope note: this covers /api/** (JWT-authenticated). The separate MVC
session path (HttpSession, form login under /mvc/**) isn't touched by this
change -- it isn't validated by JwtRequestFilter at all today (only /api/**
requests go through handleClientRequest), so closing that surface too would
need Spring Security's concurrent-session/SessionRegistry machinery, a
larger, separate piece of work.
The reset-ticket endpoint was never added to MvcSecurityConfig's permitAll
list, so anonymous requests fell through to anyRequest().authenticated()
and got redirected to /login (302) instead of creating a ticket. This
endpoint exists specifically for a rate-limited user who is, by definition,
not logged in -- every test of it this session used an authenticated admin
session (via curl with a saved cookie jar), which never exercised the
actual anonymous-caller path and masked the bug completely.
Found by writing scripts/inject_reset_tickets.py to call the endpoint the
way a real locked-out user would: no session. Its first run reported "200"
for a ticket that was never created, because Python's urllib followed the
302 to /login and reported that page's 200 instead.
/mvc/person/reset/ticket/{id}/grant (admin-only) is deliberately left off
permitAll -- it already falls through to anyRequest().authenticated() plus
the controller's own ROLE_ADMIN check, same pattern as the pre-existing
/mvc/person/reset/admin/{id}.
Verified anonymously with curl -i: 200 with no Location header, row
persisted in reset_ticket.
Calls the real POST /mvc/person/reset/ticket endpoint rather than inserting rows via SQL directly -- exercises the actual idempotency check, the per-IP rate limit (ResetCode.canRequestTicket), and doesn't risk drifting from the schema the way hand-written SQL did earlier this session (GenerationType.AUTO vs IDENTITY, see the "Ticket-creation rate limiting" section of forgot-password-pipeline.md). Uses a redirect-refusing opener rather than urllib's default: a 3xx here means the endpoint started requiring auth again (exactly the bug fixed in the previous commit) and should be reported as a failure, not silently followed and reported as a false 200. Usage: python3 scripts/inject_reset_tickets.py <uid> [<uid> ...] [--db-check]
currently contains `forgot-password-pipeline.md` for impl of oauth password reset
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an OAuth + student-ID verified password reset flow, an admin “reset ticket” escape hatch for rate-limited users, and JWT invalidation on password change (via a tokenVersion claim checked against the DB), plus supporting UI/docs/tooling.
Changes:
- Implement OAuth-verified reset endpoints and a server-side Google ID token verifier; best-effort password sync to Flask with a transport safety check.
- Add reset tickets (entity/repo/endpoints + admin UI) to grant extra reset attempts when users hit rate limits.
- Invalidate issued JWTs on password change by stamping tokens with
tokenVersionand bumping it whenever the password actually changes.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/resources/templates/person/read.html | Admin UI panel + JS action to grant reset-ticket attempts. |
| src/main/resources/templates/login.html | Repoints “Forgot Password?” to the pages support/reset wizard. |
| src/main/java/com/open/spring/security/MvcSecurityConfig.java | Permits anonymous access to new reset/ticket endpoints. |
| src/main/java/com/open/spring/security/JwtTokenUtil.java | Adds tokenVersion claim and DB check to revoke JWTs after password change. |
| src/main/java/com/open/spring/mvc/person/ResetTicketJpaRepository.java | JPA repository for querying open/reset tickets. |
| src/main/java/com/open/spring/mvc/person/ResetTicket.java | New ResetTicket entity to persist reset-assistance requests. |
| src/main/java/com/open/spring/mvc/person/PersonViewController.java | Adds OAuth reset + ticket endpoints and wires ticket list into admin portal. |
| src/main/java/com/open/spring/mvc/person/PersonDetailsService.java | Bumps tokenVersion when password changes. |
| src/main/java/com/open/spring/mvc/person/Person.java | Adds persisted tokenVersion field. |
| src/main/java/com/open/spring/mvc/person/GoogleIdTokenVerifier.java | Server-side verification via Google tokeninfo endpoint. |
| src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java | Best-effort Spring→Flask password sync with loopback/HTTPS gating. |
| src/main/java/com/open/spring/mvc/person/Email/ResetCode.java | Adds ticket rate limiting + bonus-attempt plumbing + secret resolution changes. |
| scripts/inject_reset_tickets.py | Script to create reset tickets through the real endpoint (no redirect following). |
| docs/forgot-password-pipeline.md | End-to-end documentation of the new reset pipeline. |
Suppressed comments (1)
src/main/java/com/open/spring/mvc/person/PersonViewController.java:626
- More
/reset/oauth/verifydenial paths still return distinguishable statuses/bodies (default-account guard returns 401; rate limit returns 429 with an empty body). If the goal is “indistinguishable failures”, these should also go throughoauthResetDenied(...)(same JSON body) and log the true reason server-side.
//dont allow people to reset password of default users (such as toby)
Person[] databasePersons = Person.init();
for (Person person : databasePersons) {
if (person.getUid().equals(personToReset.getUid())) {
return new ResponseEntity<Object>(HttpStatus.UNAUTHORIZED);
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+612
to
+620
| //person not found | ||
| if (personToReset == null) { | ||
| return new ResponseEntity<Object>(HttpStatus.NO_CONTENT); | ||
| } | ||
|
|
||
| //don't allow people to reset the passwords of admins | ||
| if (personToReset.getRoles().stream().anyMatch(role -> "ROLE_ADMIN".equals(role.getName()))) { | ||
| return new ResponseEntity<Object>(HttpStatus.UNAUTHORIZED); | ||
| } |
Comment on lines
+535
to
+542
| if (!ResetCode.canRequestTicket(servletRequest.getRemoteAddr())) { | ||
| return new ResponseEntity<>(HttpStatus.TOO_MANY_REQUESTS); | ||
| } | ||
|
|
||
| Person personToReset = repository.getByUid(requestBody.getUid()); | ||
| if (personToReset == null) { | ||
| return new ResponseEntity<>(HttpStatus.NO_CONTENT); | ||
| } |
Comment on lines
+5
to
+8
| > ### ⚠️ REQUIRED BEFORE THIS DEPLOYS TO PRODUCTION | ||
| > | ||
| > The `token_version` migration (see "Session/token invalidation" below) has only been applied to the **local dev SQLite databases**. Production Flask runs **MySQL** (`__init__.py` — `SQLALCHEMY_DATABASE_URI` switches to MySQL whenever `DB_ENDPOINT`/`DB_USERNAME`/`DB_PASSWORD` are set), which is a completely separate database this session had no access to. **Someone must run this against production before the Flask code ships, or every login there will start throwing errors on the missing column:** | ||
| > |
Comment on lines
170
to
172
| public static synchronized String GenerateResetCode(String uid){ | ||
| if (!canIssueResetCode(uid)) { | ||
| logger.warn("AUDIT reset_token_issue_blocked uid={} reason={}", uid, getLastIssueReason(uid)); |
Comment on lines
+55
to
+56
| <a th:href="'https://github.com/' + ${ticket.uid}" target="_blank" | ||
| th:text="${ticket.uid}">User UID</a> |
Contributor
|
There are many conventions that need review...
In addition, I am not sure about some fundamentals on workflow mentioned in oauth. |
This was referenced Aug 24, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
tl;dr Changes
spring: OAuth verified password reset, reset tickets, and JWT invalidation
Schema and config, already handled
Both schema changes this branch needs have already been applied to production, so nothing
needs to run alongside this merge. For reference:
(SQLite syntax shown, matching local dev.)
Environment is set up too.
RESET_TOKEN_SECRETis now required, since the app refuses toissue or validate reset tokens without it instead of falling back to a random key.
INTERNAL_SYNC_KEYmatches Flask's value, andFLASK_URIresolves to loopback, so thepassword sync passes the transport check rather than being skipped.
Changes
OAuth plus student ID verified reset endpoints (
PersonViewController)POST /mvc/person/reset/oauth/verify: the caller proves they own the account by signing inwith their school Google account. The trailing 5 digits of that server-verified email
(format is name, last initial, 5 digits, at
stu.powayusd.com) must match the last 5 digitsof the student ID already on file for that uid. On success it issues a single use token
through the existing
ResetCodeinfrastructure (HMAC signed, 5 minute TTL, rate limited),returned directly instead of emailed, since identity is already proven.
email based
/reset/startflow.enumerate valid uid and student ID pairs. The real reason only goes to the server log as an
audit line.
POST /mvc/person/reset/oauth/complete: spends the single use token and sets the newpassword. It trusts the token, not the client's earlier claim of being verified. Enforces an
8 character minimum, which Spring's account creation endpoint still does not have.
GoogleIdTokenVerifierendpoint, checking
aud,issandemail_verified.That is fine as a UX nicety but not acceptable as a security gate, so this flow verifies
independently.
FlaskPasswordSyncPOST /api/internal/sync-passwordwith theINTERNAL_SYNC_KEYshared secret, so bothbackends have the same password for that account. A sync failure is logged, not fatal,
since the Spring side reset has already succeeded.
encryption. The deployment topology (both nginx configs front the same public IP and proxy
to localhost, and both READMEs describe deploying through the same cockpit) says this is
same-host loopback traffic today, but no tracked config in either repo actually pins
FLASK_URIto that. So there is now a transport check before the call: allow loopback(localhost or 127.0.0.1, any scheme) or any https URI, otherwise skip the sync and log why.
The host is parsed with
java.net.URIrather than string prefix matching, so a lookalikelike
http://localhost.attacker.comcannot slip past.Reset tickets, the admin escape hatch (
ResetTicket,ResetTicketJpaRepository,ResetCode,person/read.html)POST /mvc/person/reset/ticket: a user who hits the reset rate limit can raise a ticketinstead of waiting out the window. Idempotent per uid, so a uid with an open ticket does
not get a second one.
Attempts" button per row.
POST /mvc/person/reset/ticket/{id}/grantlifts that uid's ratelimit by a batch of 5 extra attempts (
ResetCode.grantBonusAttempts) and closes the ticket.If the user needs more after that, they raise a new ticket.
does not stop someone paging through many different uids to spam the admin queue. Added a
limit of 5 requests per 15 minutes per caller IP (
ResetCode.canRequestTicket), separatefrom the global
RateLimitFilter, which is tuned for gross abuse and not this pattern.is renamed or removed later.
Two bugs found while auditing this flow
ResetCoderesolvedRESET_TOKEN_SECRETthroughSystem.getenv()only, which never seesvalues coming from Spring's
.envimport, so it was silently signing tokens with a randomper-restart key. That quietly invalidated every outstanding reset token on every restart.
It now resolves through Dotenv like the rest of the reset code, and fails closed instead of
falling back to an ephemeral key.
ResetTicketusedGenerationType.AUTO, which resolves to sequence-table id generation onthis SQLite dialect, and no such sequence table exists under
ddl-auto=none. Every realticket creation was failing with a 500. Switched to
GenerationType.IDENTITY, matching everyother SQLite backed entity here.
Security config fix
POST /mvc/person/reset/ticketwas never added toMvcSecurityConfig's permitAll list, soanonymous requests fell through to
anyRequest().authenticated()and got a 302 to/logininstead of creating a ticket. That endpoint exists specifically for a rate limited user who
by definition is not logged in. Every earlier test used an authenticated admin session
through curl with a saved cookie jar, which never touched the anonymous path and hid the bug
completely.
/mvc/person/reset/ticket/{id}/grantis deliberately left off permitAll. It falls through toanyRequest().authenticated()plus the controller's own ROLE_ADMIN check, the same patternas the existing
/mvc/person/reset/admin/{id}./reset/startand/reset/checkentries, and to the policy map used for the route audit.Invalidate sessions and JWTs when a user's password changes
Spring's JWT carried only
subandroles, nothing derived from the password, andvalidateTokenonly checked that the username matched and the static 12 hour expiry had notpassed. There was no server side revocation at all. A stolen JWT kept working for the rest of
its lifetime even after the account's password was reset, which defeats the point of a reset
that was triggered because compromise is suspected.
Person.tokenVersion, bumped inPersonDetailsService.save()wheneversamePasswordis false. That is the single funnel every password change path already goes through: OAuth
reset, email code reset, admin reset, and self service profile update.
generateTokenstamps the token with the person's currenttokenVersion, andvalidateTokenrejects the token when it does not match the current database value./api/**, which is the JWT authenticated surface. The separate MVCsession path (HttpSession, form login under
/mvc/**) is not touched, because it is notvalidated by
JwtRequestFilterat all today. Only/api/**requests go throughhandleClientRequest. Closing that surface too would need Spring Security'sconcurrent-session and SessionRegistry machinery, which is a larger separate piece of work.
Verified live: fresh JWT returns 200 on a protected
/api/**route, then an admin passwordreset on that account makes the same old JWT return 401, and a fresh login returns 200 again.
Login page link
login.html's "Forgot Password?" pointed at the old email code reset flow. It now points atthe pages site's verified reset wizard (
/support?topic=reset, localhost:4000 in dev). Theold
/mvc/person/resetflow is still reachable directly for anyone holding that link, it isjust no longer advertised as the main path.
Docs and tooling
docs/forgot-password-pipeline.md: full writeup of the pipeline, the security decisions andthe required production schema changes.
scripts/inject_reset_tickets.py: creates tickets through the real endpoint rather thaninserting rows with SQL, so it exercises the actual idempotency check and per-IP rate limit
and cannot drift from the schema. It refuses to follow redirects on purpose: a 3xx means the
endpoint started requiring auth again, which is the bug above, and should be reported as a
failure instead of being followed and reported as a false 200.
Usage:
python3 scripts/inject_reset_tickets.py <uid> [<uid> ...] [--db-check]