Skip to content

Feature Changes (detailed in description) - #168

Open
dhyantsoni wants to merge 10 commits into
Open-Coding-Society:masterfrom
dhyantsoni:master
Open

Feature Changes (detailed in description)#168
dhyantsoni wants to merge 10 commits into
Open-Coding-Society:masterfrom
dhyantsoni:master

Conversation

@dhyantsoni

Copy link
Copy Markdown

tl;dr Changes

  • Password reset button
  • Support page
  • Password databases syncing
  • Security for transfers of data between two dbs
  • Ticketing for password reset attempts (acceptable through the admin account)

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:

ALTER TABLE person ADD COLUMN token_version bigint NOT NULL DEFAULT 0;

CREATE TABLE IF NOT EXISTS "reset_ticket" (
  "attempts_granted" integer not null,
  "created_at" varchar(255),
  "name" varchar(255),
  "resolved" boolean not null,
  "resolved_at" varchar(255),
  "uid" varchar(255) not null,
  "id" integer,
  primary key ("id")
);

(SQLite syntax shown, matching local dev.)

Environment is set up too. RESET_TOKEN_SECRET is now required, since the app refuses to
issue or validate reset tokens without it instead of falling back to a random key.
INTERNAL_SYNC_KEY matches Flask's value, and FLASK_URI resolves to loopback, so the
password 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 in
    with 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 digits
    of the student ID already on file for that uid. On success it issues a single use token
    through the existing ResetCode infrastructure (HMAC signed, 5 minute TTL, rate limited),
    returned directly instead of emailed, since identity is already proven.
  • Reuses the existing admin-account and default-account guards and rate limiting from the
    email based /reset/start flow.
  • Every failure path returns an identical generic 403, so the endpoint cannot be used to
    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 new
    password. 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.

GoogleIdTokenVerifier

  • Verifies the Google Identity Services ID token server side through Google's tokeninfo
    endpoint, checking aud, iss and email_verified.
  • The existing signup flow only ever decoded this token client side and never verified it.
    That is fine as a UX nicety but not acceptable as a security gate, so this flow verifies
    independently.

FlaskPasswordSync

  • After a successful reset here, best effort calls Flask's new
    POST /api/internal/sync-password with the INTERNAL_SYNC_KEY shared secret, so both
    backends have the same password for that account. A sync failure is logged, not fatal,
    since the Spring side reset has already succeeded.
  • The request body carries the new password in plaintext, protected only by a header, not by
    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_URI to 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.URI rather than string prefix matching, so a lookalike
    like http://localhost.attacker.com cannot 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 ticket
    instead of waiting out the window. Idempotent per uid, so a uid with an open ticket does
    not get a second one.
  • Admins see an open ticket table at the top of the person/read portal, with a "Grant 5
    Attempts" button per row. POST /mvc/person/reset/ticket/{id}/grant lifts that uid's rate
    limit 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.
  • Ticket creation is unauthenticated and takes an arbitrary uid, so per-uid idempotency alone
    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), separate
    from the global RateLimitFilter, which is tuned for gross abuse and not this pattern.
  • Tickets snapshot the person's name at request time, so the row stays readable if the account
    is renamed or removed later.

Two bugs found while auditing this flow

  • ResetCode resolved RESET_TOKEN_SECRET through System.getenv() only, which never sees
    values coming from Spring's .env import, so it was silently signing tokens with a random
    per-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.
  • ResetTicket used GenerationType.AUTO, which resolves to sequence-table id generation on
    this SQLite dialect, and no such sequence table exists under ddl-auto=none. Every real
    ticket creation was failing with a 500. Switched to GenerationType.IDENTITY, matching every
    other SQLite backed entity here.

Security config fix

  • POST /mvc/person/reset/ticket was never added to MvcSecurityConfig's permitAll list, so
    anonymous requests fell through to anyRequest().authenticated() and got a 302 to /login
    instead 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}/grant is deliberately left off permitAll. It falls through to
    anyRequest().authenticated() plus the controller's own ROLE_ADMIN check, the same pattern
    as the existing /mvc/person/reset/admin/{id}.
  • Both new OAuth endpoints were added to permitAll next to the existing /reset/start and
    /reset/check entries, 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 sub and roles, nothing derived from the password, and
validateToken only checked that the username matched and the static 12 hour expiry had not
passed. 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.

  • Added Person.tokenVersion, bumped in PersonDetailsService.save() whenever samePassword
    is 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.
  • generateToken stamps the token with the person's current tokenVersion, and
    validateToken rejects the token when it does not match the current database value.
  • Scope note: this covers /api/**, which is the JWT authenticated surface. The separate MVC
    session path (HttpSession, form login under /mvc/**) is not touched, because it is not
    validated by JwtRequestFilter at all today. Only /api/** requests go through
    handleClientRequest. Closing that surface too would need Spring Security's
    concurrent-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 password
reset 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 at
    the pages site's verified reset wizard (/support?topic=reset, localhost:4000 in dev). The
    old /mvc/person/reset flow is still reachable directly for anyone holding that link, it is
    just no longer advertised as the main path.

Docs and tooling

  • docs/forgot-password-pipeline.md: full writeup of the pipeline, the security decisions and
    the required production schema changes.
  • scripts/inject_reset_tickets.py: creates tickets through the real endpoint rather than
    inserting 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]

RudraBJoshi and others added 10 commits August 19, 2026 20:19
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
Copilot AI lite review requested due to automatic review settings August 23, 2026 19:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 tokenVersion and 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/verify denial 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 through oauthResetDenied(...) (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>
@jm1021

jm1021 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

There are many conventions that need review...

  1. Overuse of static
  2. Hard-coded elements like powayusd

In addition, I am not sure about some fundamentals on workflow mentioned in oauth.

@RudraBJoshi

Copy link
Copy Markdown

Split into smaller PRs for review: #169 (password sync utility), #170 (OAuth reset flow), #171 (reset-ticket escape hatch), #172 (sync transport hardening). Marking this draft — merge order is #169#170/#172, #171 independent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants