From 08c274fe30146f57e7077edb5681a126ca46ab07 Mon Sep 17 00:00:00 2001 From: rudrabjoshi Date: Mon, 24 Aug 2026 11:22:37 -0700 Subject: [PATCH 1/3] Add reset-ticket admin escape hatch with per-IP rate limiting 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). Ticket creation is unauthenticated and takes an arbitrary uid, so its per-uid idempotency check alone doesn't stop someone paging through many different uids to spam the admin 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. Also fixes a silent 500 on every real ticket-creation request: 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 every other SQLite-backed entity here. Co-Authored-By: Claude Sonnet 5 --- .../spring/mvc/person/Email/ResetCode.java | 35 ++++++++- .../mvc/person/PersonViewController.java | 72 +++++++++++++++++++ .../open/spring/mvc/person/ResetTicket.java | 55 ++++++++++++++ .../mvc/person/ResetTicketJpaRepository.java | 10 +++ src/main/resources/templates/person/read.html | 53 ++++++++++++++ 5 files changed, 224 insertions(+), 1 deletion(-) 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..82d6ca0c 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 @@ -26,9 +26,33 @@ 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(); + // Ticket creation is unauthenticated and uid-idempotent (one open ticket per uid), so + // that alone doesn't stop a caller from paging through many *different* uids to spam the + // admin queue -- rate-limit by caller IP instead, separately from the uid-keyed limits + // above. + private static final long TICKET_RATE_WINDOW_SECONDS = 15 * 60; + private static final int MAX_TICKET_REQUESTS_PER_WINDOW = 5; + private static final Map> ticketRequestTimesByIp = new ConcurrentHashMap<>(); + + public static synchronized boolean canRequestTicket(String ip) { + long now = Instant.now().getEpochSecond(); + Deque requestTimes = ticketRequestTimesByIp.computeIfAbsent(ip, key -> new ArrayDeque<>()); + while (!requestTimes.isEmpty() && requestTimes.peekFirst() <= now - TICKET_RATE_WINDOW_SECONDS) { + requestTimes.removeFirst(); + } + if (requestTimes.size() >= MAX_TICKET_REQUESTS_PER_WINDOW) { + return false; + } + requestTimes.addLast(now); + return true; + } + private static class ResetTokenRecord { private final String token; private final long expiresAtEpoch; @@ -89,7 +113,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 +127,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 40f5b3bf..333cc69e 100644 --- a/src/main/java/com/open/spring/mvc/person/PersonViewController.java +++ b/src/main/java/com/open/spring/mvc/person/PersonViewController.java @@ -37,6 +37,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import jakarta.servlet.http.HttpServletRequest; + // Built using article: https://docs.spring.io/spring-framework/docs/3.2.x/spring-framework-reference/html/mvc.html // or similar: https://asbnotebook.com/2020/04/11/spring-boot-thymeleaf-form-validation-example/ @Controller @@ -51,6 +53,9 @@ public class PersonViewController { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private ResetTicketJpaRepository ticketRepository; + //@Autowired //private PersonJpaRepository find; @@ -71,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 @@ -503,6 +509,72 @@ 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. + // Unauthenticated and takes an arbitrary uid, so it's also rate-limited per caller IP + // (separately from the global RateLimitFilter) -- otherwise a caller could page through + // many different real uids and spam the admin's ticket queue without ever tripping the + // per-uid idempotency check above. + @PostMapping("/reset/ticket") + public ResponseEntity requestResetTicket(@RequestBody ResetTicketRequestBody requestBody, + HttpServletRequest servletRequest) { + if (requestBody == null || requestBody.getUid() == null || requestBody.getUid().isBlank()) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + + 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); + } + + 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); + } + /////////////////////////////////////////////////////////////////////////////////////////// /// "Cookie-Clicker" Post and Get mappings /// 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..90f7eb58 --- /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.IDENTITY) + 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/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 @@ } + +