From c33e6d7044ecad6c2faa3f9169856be278fa1e08 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Wed, 19 Aug 2026 20:19:48 -0700 Subject: [PATCH 01/10] Add OAuth + student ID verified password reset endpoints 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 --- .../spring/mvc/person/FlaskPasswordSync.java | 84 +++++++++++ .../mvc/person/GoogleIdTokenVerifier.java | 82 +++++++++++ .../mvc/person/PersonViewController.java | 136 ++++++++++++++++++ .../spring/security/MvcSecurityConfig.java | 4 + 4 files changed, 306 insertions(+) create mode 100644 src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java create mode 100644 src/main/java/com/open/spring/mvc/person/GoogleIdTokenVerifier.java diff --git a/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java b/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java new file mode 100644 index 00000000..ac393ba0 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java @@ -0,0 +1,84 @@ +package com.open.spring.mvc.person; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.github.cdimascio.dotenv.Dotenv; + +// Server-to-server call into Flask's /api/internal/sync-password, so a password +// reset completed here (OAuth + student ID verified) also lands on the Flask +// account for the same uid. Gated by a shared secret (INTERNAL_SYNC_KEY) that +// must match Flask's own config -- see GoogleIdTokenVerifier for the same +// env-then-dotenv resolution pattern used here. +public class FlaskPasswordSync { + private static final Logger logger = LoggerFactory.getLogger(FlaskPasswordSync.class); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private static String resolve(String envKey, String fallback) { + String value = System.getenv(envKey); + if (value != null && !value.isBlank()) { + return value; + } + try { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + value = dotenv.get(envKey); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception e) { + // fall through to default + } + return fallback; + } + + // Best-effort: the Spring-side reset has already succeeded by the time this is + // called, so a Flask sync failure is logged and swallowed rather than failing + // the whole request -- the user's new password is already live on Spring, + // which is the backend this feature actually verified identity against. + public static boolean syncPassword(String uid, String newPassword) { + String syncKey = resolve("INTERNAL_SYNC_KEY", null); + String flaskUri = resolve("FLASK_URI", "http://localhost:8587"); + + if (syncKey == null) { + logger.warn("AUDIT flask_password_sync_skipped uid={} reason=no_sync_key_configured", uid); + return false; + } + + try { + JSONObject payload = new JSONObject(); + payload.put("uid", uid); + payload.put("password", newPassword); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(flaskUri + "/api/internal/sync-password")) + .header("Content-Type", "application/json") + .header("X-Internal-Sync-Key", syncKey) + .timeout(Duration.ofSeconds(10)) + .POST(HttpRequest.BodyPublishers.ofString(payload.toString(), StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + logger.info("AUDIT flask_password_sync_succeeded uid={}", uid); + return true; + } + + logger.warn("AUDIT flask_password_sync_failed uid={} status={}", uid, response.statusCode()); + return false; + } catch (Exception e) { + logger.warn("AUDIT flask_password_sync_failed uid={} reason=exception msg={}", uid, e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/open/spring/mvc/person/GoogleIdTokenVerifier.java b/src/main/java/com/open/spring/mvc/person/GoogleIdTokenVerifier.java new file mode 100644 index 00000000..bfe2e815 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/person/GoogleIdTokenVerifier.java @@ -0,0 +1,82 @@ +package com.open.spring.mvc.person; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.open.spring.mvc.person.HttpRequest.HttpSender; + +import io.github.cdimascio.dotenv.Dotenv; + +// Verifies a Google Identity Services ID token server-side via Google's tokeninfo +// endpoint, so callers never trust an email a client merely claims to have signed in with. +public class GoogleIdTokenVerifier { + private static final Logger logger = LoggerFactory.getLogger(GoogleIdTokenVerifier.class); + + // Same public client ID hardcoded in navigation/authentication/login.md's GOOGLE_CLIENT_ID. + // Client IDs are not secret; this is only used to check the token's "aud" claim. + private static final String DEFAULT_CLIENT_ID = "65827797404-ccjleg7jg4g2an8ddpmhnlca4ii2gk8q.apps.googleusercontent.com"; + + private static String resolveClientId() { + String value = System.getenv("GOOGLE_CLIENT_ID"); + if (value != null && !value.isBlank()) { + return value; + } + try { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + value = dotenv.get("GOOGLE_CLIENT_ID"); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception e) { + // fall through to default + } + return DEFAULT_CLIENT_ID; + } + + // Returns the verified email address, or null if the token is missing, expired, + // mis-signed, issued for a different client, or not marked email_verified by Google. + public static String verifyAndGetEmail(String idToken) { + if (idToken == null || idToken.isBlank()) { + return null; + } + + try { + String encoded = URLEncoder.encode(idToken, StandardCharsets.UTF_8); + Map response = HttpSender.sendRequest( + "https://oauth2.googleapis.com/tokeninfo?id_token=" + encoded, + "GET", + new HashMap<>() + ); + + if (!"200".equals(response.get("responseCode"))) { + logger.warn("AUDIT google_token_verify_failed reason=non_200_response code={}", response.get("responseCode")); + return null; + } + + JSONObject claims = new JSONObject(response.get("content")); + String aud = claims.optString("aud", null); + String issuer = claims.optString("iss", null); + boolean emailVerified = "true".equals(claims.optString("email_verified", null)); + String email = claims.optString("email", null); + + boolean issuerOk = "accounts.google.com".equals(issuer) || "https://accounts.google.com".equals(issuer); + boolean audOk = aud != null && aud.equals(resolveClientId()); + + if (!audOk || !issuerOk || !emailVerified || email == null || email.isBlank()) { + logger.warn("AUDIT google_token_verify_failed reason=claim_check_failed"); + return null; + } + + return email; + } catch (Exception e) { + logger.warn("AUDIT google_token_verify_failed reason=exception msg={}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/open/spring/mvc/person/PersonViewController.java b/src/main/java/com/open/spring/mvc/person/PersonViewController.java index 40f5b3bf..03316d82 100644 --- a/src/main/java/com/open/spring/mvc/person/PersonViewController.java +++ b/src/main/java/com/open/spring/mvc/person/PersonViewController.java @@ -32,6 +32,8 @@ import java.util.Arrays; import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import lombok.Getter; import org.slf4j.Logger; @@ -503,6 +505,140 @@ public ResponseEntity adminResetPassword(@PathVariable Long id, Authenti return new ResponseEntity<>(HttpStatus.OK); } + // Matches student emails shaped like "firstnamelastinitial12345@stu.powayusd.com" and + // captures the trailing 5 digits, which must match the last 5 digits of the account's sid. + private static final Pattern SCHOOL_EMAIL_DIGITS_PATTERN = + Pattern.compile("^[a-z]+([0-9]{5})@stu\\.powayusd\\.com$"); + + private ResponseEntity oauthResetDenied(HttpStatus status) { + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"verified\":false}"; + return new ResponseEntity(body, responseHeaders, status); + } + + @Getter + public static class PersonOAuthResetVerifyBody { + private String uid; + private String idToken; + } + + // Step 1 of the OAuth-verified reset: caller proves ownership of the account by signing + // in with their school Google account. The last 5 digits of that (server-verified) email + // must match the last 5 digits of the sid already on file for this uid before a reset + // token is issued. Denial responses are intentionally identical regardless of which check + // failed, so a caller can't use this endpoint to enumerate uid/sid pairs; the specific + // reason is only ever written to the server log. + @PostMapping("/reset/oauth/verify") + public ResponseEntity resetPasswordOAuthVerify(@RequestBody PersonOAuthResetVerifyBody requestBody) { + if (requestBody == null || requestBody.getUid() == null || requestBody.getUid().isBlank()) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + + Person personToReset = repository.getByUid(requestBody.getUid()); + + //person not found + if (personToReset == null) { + return new ResponseEntity(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(HttpStatus.UNAUTHORIZED); + } + + //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(HttpStatus.UNAUTHORIZED); + } + } + + // enforce active-token and rolling-window rate limits, same as the email-based flow + if (!ResetCode.canIssueResetCode(personToReset.getUid())) { + return new ResponseEntity(HttpStatus.TOO_MANY_REQUESTS); + } + + String verifiedEmail = GoogleIdTokenVerifier.verifyAndGetEmail(requestBody.getIdToken()); + if (verifiedEmail == null) { + logger.warn("AUDIT oauth_reset_denied uid={} reason=invalid_token", personToReset.getUid()); + return oauthResetDenied(HttpStatus.FORBIDDEN); + } + + Matcher matcher = SCHOOL_EMAIL_DIGITS_PATTERN.matcher(verifiedEmail.toLowerCase()); + if (!matcher.matches()) { + logger.warn("AUDIT oauth_reset_denied uid={} reason=email_format", personToReset.getUid()); + return oauthResetDenied(HttpStatus.FORBIDDEN); + } + + String emailDigits = matcher.group(1); + String sid = personToReset.getSid(); + if (sid == null || sid.length() < 5) { + logger.warn("AUDIT oauth_reset_denied uid={} reason=no_sid", personToReset.getUid()); + return oauthResetDenied(HttpStatus.FORBIDDEN); + } + + String sidDigits = sid.substring(sid.length() - 5); + if (!emailDigits.equals(sidDigits)) { + logger.warn("AUDIT oauth_reset_denied uid={} reason=sid_mismatch", personToReset.getUid()); + return oauthResetDenied(HttpStatus.FORBIDDEN); + } + + String resetToken = ResetCode.GenerateResetCode(personToReset.getUid()); + if (resetToken == null) { + return oauthResetDenied(HttpStatus.TOO_MANY_REQUESTS); + } + + logger.info("AUDIT oauth_reset_verified uid={}", personToReset.getUid()); + + HttpHeaders responseHeaders = new HttpHeaders(); + responseHeaders.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"verified\":true,\"resetToken\":\"" + resetToken + "\"}"; + return new ResponseEntity(body, responseHeaders, HttpStatus.OK); + } + + @Getter + public static class PersonOAuthResetCompleteBody { + private String uid; + private String resetToken; + private String newPassword; + } + + // Step 2: spends the single-use token issued by /reset/oauth/verify to actually set the + // new password. The token, not the client's earlier "verified" claim, is what's trusted here. + @PostMapping("/reset/oauth/complete") + public ResponseEntity resetPasswordOAuthComplete(@RequestBody PersonOAuthResetCompleteBody requestBody) { + if (requestBody == null || requestBody.getUid() == null || requestBody.getUid().isBlank()) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + + Person personToReset = repository.getByUid(requestBody.getUid()); + if (personToReset == null) { + return new ResponseEntity(HttpStatus.NO_CONTENT); + } + + if (requestBody.getNewPassword() == null || requestBody.getNewPassword().length() < 8) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + + if (!ResetCode.validateAndConsume(personToReset.getUid(), requestBody.getResetToken())) { + logger.warn("AUDIT oauth_reset_complete_denied uid={} reason=invalid_token", personToReset.getUid()); + return new ResponseEntity(HttpStatus.FORBIDDEN); + } + + personToReset.setPassword(requestBody.getNewPassword()); + repository.save(personToReset, false); + + logger.info("AUDIT oauth_reset_completed uid={}", personToReset.getUid()); + + // Best-effort sync to Flask so both backends' passwords stay in sync for this + // account; failure here doesn't roll back or fail the Spring-side reset above. + FlaskPasswordSync.syncPassword(personToReset.getUid(), requestBody.getNewPassword()); + + return new ResponseEntity(HttpStatus.OK); + } + /////////////////////////////////////////////////////////////////////////////////////////// /// "Cookie-Clicker" Post and Get mappings /// diff --git a/src/main/java/com/open/spring/security/MvcSecurityConfig.java b/src/main/java/com/open/spring/security/MvcSecurityConfig.java index aa1cef24..4d5aa061 100644 --- a/src/main/java/com/open/spring/security/MvcSecurityConfig.java +++ b/src/main/java/com/open/spring/security/MvcSecurityConfig.java @@ -77,6 +77,8 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers(HttpMethod.GET, "/mvc/person/reset/check").permitAll() .requestMatchers(HttpMethod.POST, "/mvc/person/reset/start").permitAll() .requestMatchers(HttpMethod.POST, "/mvc/person/reset/check").permitAll() + .requestMatchers(HttpMethod.POST, "/mvc/person/reset/oauth/verify").permitAll() + .requestMatchers(HttpMethod.POST, "/mvc/person/reset/oauth/complete").permitAll() .requestMatchers("/mvc/person/read/**").authenticated() .requestMatchers("/mvc/person/cookie-clicker").authenticated() .requestMatchers(HttpMethod.GET,"/mvc/person/update/user").authenticated() @@ -191,6 +193,8 @@ public Map mvcEndpointRolePolicy() { policy.put("GET /mvc/person/reset/check", "permitAll"); policy.put("POST /mvc/person/reset/start", "permitAll"); policy.put("POST /mvc/person/reset/check", "permitAll"); + policy.put("POST /mvc/person/reset/oauth/verify", "permitAll"); + policy.put("POST /mvc/person/reset/oauth/complete", "permitAll"); policy.put("GET /mvc/person/update/user", "authenticated"); policy.put("POST /mvc/person/update", "authenticated (+ controller ownership checks)"); policy.put("POST /mvc/person/update/role", "ROLE_ADMIN"); From 261528bc28a2f4dd893ab25d322a159a28117a03 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Thu, 20 Aug 2026 10:31:48 -0700 Subject: [PATCH 02/10] Add reset-ticket admin escape hatch, fix reset-token secret and legacy 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. --- .../spring/mvc/person/Email/ResetCode.java | 66 +++++++++++++++---- .../mvc/person/PersonViewController.java | 61 +++++++++++++++++ .../open/spring/mvc/person/ResetTicket.java | 55 ++++++++++++++++ .../mvc/person/ResetTicketJpaRepository.java | 10 +++ src/main/resources/templates/login.html | 12 +++- src/main/resources/templates/person/read.html | 53 +++++++++++++++ 6 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/open/spring/mvc/person/ResetTicket.java create mode 100644 src/main/java/com/open/spring/mvc/person/ResetTicketJpaRepository.java diff --git a/src/main/java/com/open/spring/mvc/person/Email/ResetCode.java b/src/main/java/com/open/spring/mvc/person/Email/ResetCode.java index 15a7e5de..42ba0eb5 100644 --- a/src/main/java/com/open/spring/mvc/person/Email/ResetCode.java +++ b/src/main/java/com/open/spring/mvc/person/Email/ResetCode.java @@ -15,6 +15,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.github.cdimascio.dotenv.Dotenv; + public class ResetCode { private static final Logger logger = LoggerFactory.getLogger(ResetCode.class); @@ -26,8 +28,11 @@ public class ResetCode { private static final Map activeTokensByUid = new ConcurrentHashMap<>(); private static final Map> resetRequestTimesByUid = new ConcurrentHashMap<>(); private static final Map lastIssueReasonByUid = new ConcurrentHashMap<>(); + // Bumped by an admin from the reset-ticket queue when a rate-limited user needs more + // attempts; each grant adds one batch on top of MAX_REQUESTS_PER_WINDOW. + private static final Map bonusAttemptsByUid = new ConcurrentHashMap<>(); - private static final byte[] secret = loadSecret(); + private static volatile byte[] cachedSecret; private static class ResetTokenRecord { private final String token; @@ -39,16 +44,44 @@ private ResetTokenRecord(String token, long expiresAtEpoch) { } } - private static byte[] loadSecret() { - String envSecret = System.getenv("RESET_TOKEN_SECRET"); - if (envSecret != null && !envSecret.isBlank()) { - return envSecret.getBytes(StandardCharsets.UTF_8); + // Same env-then-.env resolution order as FlaskPasswordSync/GoogleIdTokenVerifier: plain + // System.getenv() only sees real OS environment variables, not Spring's own + // spring.config.import=.env mechanism, so a Dotenv fallback is required for local dev + // where the secret only lives in .env. + private static String resolveConfiguredSecret() { + String value = System.getenv("RESET_TOKEN_SECRET"); + if (value != null && !value.isBlank()) { + return value; } + try { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + value = dotenv.get("RESET_TOKEN_SECRET"); + if (value != null && !value.isBlank()) { + return value; + } + } catch (Exception e) { + // fall through + } + return null; + } - byte[] generated = new byte[32]; - random.nextBytes(generated); - logger.warn("AUDIT reset_secret_fallback using ephemeral in-memory secret because RESET_TOKEN_SECRET is not set"); - return generated; + // Deliberately fails closed instead of falling back to an ephemeral per-restart secret: + // a randomly generated fallback would silently invalidate every outstanding reset token + // (and undermine the HMAC's whole purpose) on every deploy, without anyone noticing. + private static byte[] getSecret() { + byte[] local = cachedSecret; + if (local != null) { + return local; + } + String configured = resolveConfiguredSecret(); + if (configured == null) { + throw new IllegalStateException( + "RESET_TOKEN_SECRET is not set. Password reset cannot issue or validate tokens " + + "without it -- set RESET_TOKEN_SECRET in the environment or .env file."); + } + local = configured.getBytes(StandardCharsets.UTF_8); + cachedSecret = local; + return local; } private static String base64Url(byte[] value) { @@ -58,8 +91,10 @@ private static String base64Url(byte[] value) { private static String hmacSha256(String payload) { try { Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); + mac.init(new SecretKeySpec(getSecret(), "HmacSHA256")); return base64Url(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (IllegalStateException e) { + throw e; } catch (Exception e) { throw new IllegalStateException("Unable to sign reset token", e); } @@ -89,7 +124,8 @@ public static synchronized boolean canIssueResetCode(String uid) { } Deque requestTimes = resetRequestTimesByUid.computeIfAbsent(uid, key -> new ArrayDeque<>()); - if (requestTimes.size() >= MAX_REQUESTS_PER_WINDOW) { + int allowedRequests = MAX_REQUESTS_PER_WINDOW + bonusAttemptsByUid.getOrDefault(uid, 0); + if (requestTimes.size() >= allowedRequests) { lastIssueReasonByUid.put(uid, "rate-limit"); return false; } @@ -102,6 +138,14 @@ public static String getLastIssueReason(String uid) { return lastIssueReasonByUid.get(uid); } + // Called by an admin resolving a reset ticket: lifts the rate limit by one batch of + // extraAttempts on top of the standard window, so the user can retry immediately. + public static synchronized void grantBonusAttempts(String uid, int extraAttempts) { + bonusAttemptsByUid.merge(uid, extraAttempts, Integer::sum); + logger.info("AUDIT reset_bonus_attempts_granted uid={} extraAttempts={} totalBonus={}", + uid, extraAttempts, bonusAttemptsByUid.get(uid)); + } + public static synchronized String GenerateResetCode(String uid){ if (!canIssueResetCode(uid)) { logger.warn("AUDIT reset_token_issue_blocked uid={} reason={}", uid, getLastIssueReason(uid)); diff --git a/src/main/java/com/open/spring/mvc/person/PersonViewController.java b/src/main/java/com/open/spring/mvc/person/PersonViewController.java index 03316d82..e6bd5694 100644 --- a/src/main/java/com/open/spring/mvc/person/PersonViewController.java +++ b/src/main/java/com/open/spring/mvc/person/PersonViewController.java @@ -53,6 +53,9 @@ public class PersonViewController { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private ResetTicketJpaRepository ticketRepository; + //@Autowired //private PersonJpaRepository find; @@ -73,6 +76,7 @@ public String person(Authentication authentication, Model model) { if (isAdmin == true){ List list = repository.listAll(); // Fetch all persons model.addAttribute("list", list); // Add the list to the model for the view + model.addAttribute("tickets", ticketRepository.findByResolvedFalseOrderByIdDesc()); } else { Person person = repository.getByUid(userDetails.getUsername()); // Fetch the person by email @@ -505,6 +509,63 @@ public ResponseEntity adminResetPassword(@PathVariable Long id, Authenti return new ResponseEntity<>(HttpStatus.OK); } + private static final int TICKET_GRANT_BATCH_SIZE = 5; + + @Getter + public static class ResetTicketRequestBody { + private String uid; + } + + // Raised by the frontend's reset wizard when a uid hits the reset rate limit, so an + // admin can step in from the person/read portal instead of the user waiting out the + // window. Idempotent: a uid with an existing open ticket won't get a second one. + @PostMapping("/reset/ticket") + public ResponseEntity requestResetTicket(@RequestBody ResetTicketRequestBody requestBody) { + if (requestBody == null || requestBody.getUid() == null || requestBody.getUid().isBlank()) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + + Person personToReset = repository.getByUid(requestBody.getUid()); + if (personToReset == null) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + if (ticketRepository.findByUidAndResolvedFalse(personToReset.getUid()).isEmpty()) { + ticketRepository.save(new ResetTicket(personToReset.getUid(), personToReset.getName())); + logger.info("AUDIT reset_ticket_created uid={}", personToReset.getUid()); + } + + return new ResponseEntity<>(HttpStatus.OK); + } + + // Admin resolves a reset ticket from the portal: grants the uid one batch of extra + // reset attempts (lifting the rate limit) and closes the ticket. If the user still + // needs more attempts after that, they raise a new ticket. + @PostMapping("/reset/ticket/{id}/grant") + public ResponseEntity grantResetTicket(@PathVariable Long id, Authentication authentication) { + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + if (!isAdmin) { + return new ResponseEntity<>(HttpStatus.FORBIDDEN); + } + + ResetTicket ticket = ticketRepository.findById(id).orElse(null); + if (ticket == null) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + if (ticket.isResolved()) { + return new ResponseEntity<>(HttpStatus.OK); + } + + ResetCode.grantBonusAttempts(ticket.getUid(), TICKET_GRANT_BATCH_SIZE); + ticket.markResolved(TICKET_GRANT_BATCH_SIZE); + ticketRepository.save(ticket); + + logger.warn("AUDIT reset_ticket_granted admin={} target_uid={} batch={}", + authentication.getName(), ticket.getUid(), TICKET_GRANT_BATCH_SIZE); + return new ResponseEntity<>(HttpStatus.OK); + } + // Matches student emails shaped like "firstnamelastinitial12345@stu.powayusd.com" and // captures the trailing 5 digits, which must match the last 5 digits of the account's sid. private static final Pattern SCHOOL_EMAIL_DIGITS_PATTERN = diff --git a/src/main/java/com/open/spring/mvc/person/ResetTicket.java b/src/main/java/com/open/spring/mvc/person/ResetTicket.java new file mode 100644 index 00000000..3245567b --- /dev/null +++ b/src/main/java/com/open/spring/mvc/person/ResetTicket.java @@ -0,0 +1,55 @@ +package com.open.spring.mvc.person; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +// Raised by the frontend when a user hits the reset rate limit and asks for admin help +// instead. An admin resolves it from the person/read portal, which grants the uid a batch +// of extra reset attempts via ResetCode.grantBonusAttempts. +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class ResetTicket { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @NotNull + private String uid; + + // Snapshot of the person's name at request time, so the ticket stays readable even if + // the account is later renamed or removed. + private String name; + + private boolean resolved = false; + + private String createdAt; + + private String resolvedAt; + + private int attemptsGranted = 0; + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public ResetTicket(String uid, String name) { + this.uid = uid; + this.name = name; + this.createdAt = LocalDateTime.now().format(FORMATTER); + } + + public void markResolved(int attemptsGranted) { + this.resolved = true; + this.attemptsGranted = attemptsGranted; + this.resolvedAt = LocalDateTime.now().format(FORMATTER); + } +} diff --git a/src/main/java/com/open/spring/mvc/person/ResetTicketJpaRepository.java b/src/main/java/com/open/spring/mvc/person/ResetTicketJpaRepository.java new file mode 100644 index 00000000..84d111f8 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/person/ResetTicketJpaRepository.java @@ -0,0 +1,10 @@ +package com.open.spring.mvc.person; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ResetTicketJpaRepository extends JpaRepository { + List findByResolvedFalseOrderByIdDesc(); + List findByUidAndResolvedFalse(String uid); +} diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 2856d841..e7651dd9 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -48,8 +48,11 @@

Login To Account

Sign Up - - Forgot Password? + + Forgot Password? @@ -61,6 +64,11 @@

Login To Account

form.addEventListener("submit", (event) => { event.submitter.innerHTML = ""; }) + + const pagesOrigin = (location.hostname === "localhost" || location.hostname === "127.0.0.1") + ? "http://localhost:4000" + : "https://pages.opencodingsociety.com"; + document.getElementById("forgot-password-link").href = pagesOrigin + "/support?topic=reset"; diff --git a/src/main/resources/templates/person/read.html b/src/main/resources/templates/person/read.html index 3b63bce3..aabd9805 100644 --- a/src/main/resources/templates/person/read.html +++ b/src/main/resources/templates/person/read.html @@ -34,6 +34,39 @@

Person Viewer

+ +
+
Password Reset Tickets
+
+ + + + + + + + + + + + + + + + + +
RequestedUIDNameAction
Requested At + User UID + Name + +
+
+
+
@@ -191,6 +224,26 @@ } + +