flask: Add internal password sync endpoint + strip password hash from API responses (2/3 split of #73) - #75
Conversation
POST /api/internal/sync-password: updates a user's password by uid, called server-to-server by the Spring backend after it completes an OAuth + student ID verified password reset, so the same account's Flask password doesn't drift out of sync with Spring's. Gated by a shared secret (INTERNAL_SYNC_KEY, compared with hmac.compare_digest for timing-safety) instead of user auth, since this is never called from a browser -- there's no existing service-to-service auth mechanism in this app to reuse, and reusing the admin-only PUT /api/user route would have meant giving Spring real Flask admin credentials. This endpoint can only ever change one user's password, and is a closed no-op if INTERNAL_SYNC_KEY is unset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GET /api/user (any logged-in user, not just admins) and the other UserAPI create/update/delete responses were including the PBKDF2 hash from User.read() in the JSON body. Adds _without_password() and applies it at every general-purpose response site; the admin-only backup/export endpoints in data_export_import_api.py are left alone since they need the hash for restore fidelity.
_InternalPasswordSync always returned 200 after calling user.update(), even though update() returns None on IntegrityError (rolled back internally). That meant Spring could be told a sync succeeded when the write never actually happened. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…word jm1021 closed spring #169 (which called this endpoint) as violating the project's architecture: Spring must never push to Flask directly. This endpoint was the Flask side of that banned call, gated by a static shared secret and reachable only from Spring's server. Replaced _InternalPasswordSync with _ResetPasswordVerified: called directly by the frontend (pages' support.md), not by Spring. It verifies the caller's resetToken itself -- the same HMAC-SHA256-signed token Spring's ResetCode.java issues -- using a RESET_TOKEN_SECRET both backends are independently configured with, entirely locally (no network call back to Spring). Flask becomes the source of truth for this password write; the frontend syncs Spring's copy afterward by calling Spring's /reset/oauth/complete with the same token. Also added local single-use enforcement for the token (a small in-process consumed-token set), since Flask has no visibility into Spring's own single-use tracking and the old endpoint's stateless shared-secret auth didn't need this at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Replaced the internal server-to-server sync endpoint with a frontend-facing one, per `jm1021`'s review on spring #169. Why: `jm1021` closed #169 (the Spring PR that called this endpoint) as violating the project's architecture — Spring must never push a password to Flask directly. This endpoint was the Flask side of that banned call, gated by a static shared secret and reachable only from Spring's own server. Fix: `_InternalPasswordSync` → `_ResetPasswordVerified` (`POST /api/reset-password`). Called directly by the frontend now, not by Spring. It verifies the caller's `resetToken` itself — the same HMAC-SHA256-signed token Spring's `ResetCode.java` issues — using a `RESET_TOKEN_SECRET` both backends are independently configured with, entirely locally (no network call back to Spring). Flask becomes the source of truth for this password write; the frontend syncs Spring's copy afterward by calling Spring's `/reset/oauth/complete` with the same token. Also added local single-use enforcement for the token, since Flask has no visibility into Spring's own tracking and the old shared-secret auth didn't need it. Companion changes: spring #170 (removes the call site + `FlaskPasswordSync`), pages #1374 (calls this endpoint before Spring's). Verified locally end-to-end: cross-language HMAC compatibility with Spring's token issuance, and a real reset through this endpoint (login with new password succeeds, old password rejected, tampered/wrong-uid/expired/replayed tokens all correctly rejected). |
Splitting #73 into smaller, independently-reviewable PRs across spring/flask/pages. This one covers password database syncing, security for transfers of data between the two dbs, and a related hash-leak fix (kept together — the sync endpoint's auth check isn't meaningfully separable from the endpoint itself without landing an insecure intermediate state).
POST /api/internal/sync-password: updates a user's password by uid, called server-to-server by Spring after an OAuth-verified reset. Gated by a shared secret (INTERNAL_SYNC_KEY, compared withhmac.compare_digestfor timing-safety), fails closed if unset.Password hash no longer returned by the general user API:
GET /api/user, the bulk user list, and the create/update/delete/guest-create responses were all including the PBKDF2 hash in the JSON body, to any logged-in user, not just admins. Admin-only backup/export endpoints are deliberately left alone since a restore needs the hash.Original PR: #73