Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions docs/forgot-password-pipeline.md

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions scripts/inject_reset_tickets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Create test reset tickets by calling the real POST /mvc/person/reset/ticket
endpoint, instead of hand-writing SQL against reset_ticket.

Goes through the actual endpoint on purpose: it's idempotent per uid (won't
double-create), rate-limited to 5 requests / 15 min per caller IP
(ResetCode.canRequestTicket), and its schema (GenerationType.IDENTITY) has
already bitten one direct-SQL testing pass this session that never exercised
the endpoint itself -- see forgot-password-pipeline.md's "Ticket-creation
rate limiting" section for that story. Hitting the endpoint is what actually
proves the whole path (idempotency, rate limit, admin panel query) works,
not just that a row exists.

Usage:
python3 scripts/inject_reset_tickets.py hop niko
python3 scripts/inject_reset_tickets.py --db-check hop
BASE_URL=http://localhost:8585 python3 scripts/inject_reset_tickets.py hop

After running, open /mvc/person/read as an admin to see the "Password Reset
Tickets" panel, or use --db-check to confirm without a browser.
"""

from __future__ import annotations

import argparse
import os
import sqlite3
import sys
from pathlib import Path
from urllib import request

BASE_URL = os.getenv("BASE_URL", "http://localhost:8585")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DB = PROJECT_ROOT / "volumes" / "sqlite.db"


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create test reset tickets via the real reset-ticket endpoint."
)
parser.add_argument(
"uids",
nargs="+",
help="GitHub uid(s) to raise a reset ticket for (max 5 per run -- see rate limit note below)",
)
parser.add_argument(
"--db-check",
action="store_true",
help="After creating, print each uid's open-ticket row from the DB",
)
parser.add_argument(
"--db",
default=str(DEFAULT_DB),
help=f"SQLite DB path for --db-check (default: {DEFAULT_DB})",
)
return parser.parse_args()


class NoRedirectHandler(request.HTTPRedirectHandler):
"""Turn a 3xx into a raised HTTPError instead of silently following it.

This endpoint must be reachable with zero auth (see the security-config
comment in MvcSecurityConfig.java) -- if it's ever accidentally dropped
from permitAll again, Spring redirects an anonymous POST to /login (302)
instead of rejecting it, and urllib's default opener follows that
transparently and reports the login page's 200 as if the ticket had been
created. That exact bug shipped once already; this handler is what
would have caught it immediately instead of needing a manual curl -i.
"""

def redirect_request(self, req, fp, code, msg, headers, newurl):
return None


OPENER = request.build_opener(NoRedirectHandler)


def create_ticket(uid: str) -> tuple[int, str]:
url = f"{BASE_URL}/mvc/person/reset/ticket"
body = ('{"uid":"%s"}' % uid).encode("utf-8")
req = request.Request(
url, data=body, method="POST", headers={"Content-Type": "application/json"}
)
try:
with OPENER.open(req) as resp:
return resp.status, resp.read().decode("utf-8", errors="replace")
except Exception as exc:
status = getattr(exc, "code", 0)
body_bytes = exc.read() if hasattr(exc, "read") else b""
return status, body_bytes.decode("utf-8", errors="replace")


STATUS_MEANING = {
200: "created (or an open ticket already existed for this uid)",
204: "no such uid -- person not found",
400: "bad request -- uid missing/blank",
302: "REDIRECTED TO LOGIN -- endpoint is requiring auth, nothing was created. "
"Check MvcSecurityConfig has POST /mvc/person/reset/ticket in permitAll().",
429: "rate-limited: 5 ticket-creation requests / 15 min per caller IP already used",
}


def print_db_check(db_path: Path, uids: list[str]) -> None:
if not db_path.exists():
print(f"\n--db-check: database file not found: {db_path}")
return

conn = sqlite3.connect(str(db_path))
try:
cur = conn.cursor()
print(f"\n--db-check ({db_path}):")
for uid in uids:
cur.execute(
'SELECT id, resolved, created_at FROM reset_ticket WHERE uid = ? ORDER BY id DESC LIMIT 1',
(uid,),
)
row = cur.fetchone()
if row is None:
print(f" {uid}: no reset_ticket row found")
else:
ticket_id, resolved, created_at = row
state = "open" if not resolved else "resolved"
print(f" {uid}: ticket #{ticket_id} ({state}), created {created_at}")
finally:
conn.close()


def main() -> int:
args = parse_args()

if len(args.uids) > 5:
print(
f"Note: {len(args.uids)} uids given, but the endpoint only allows 5 "
"ticket-creation requests per 15 min per caller IP -- the rest will "
"come back 429 in this same run.\n"
)

for uid in args.uids:
status, body = create_ticket(uid)
meaning = STATUS_MEANING.get(status, "unexpected status")
print(f"{uid}: POST /mvc/person/reset/ticket -> {status} ({meaning})")
if status not in (200,) and body:
print(f" body: {body[:300]}")

if args.db_check:
print_db_check(Path(args.db).expanduser().resolve(), args.uids)

return 0


if __name__ == "__main__":
sys.exit(main())
87 changes: 76 additions & 11 deletions src/main/java/com/open/spring/mvc/person/Email/ResetCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -26,8 +28,32 @@ public class ResetCode {
private static final Map<String, ResetTokenRecord> activeTokensByUid = new ConcurrentHashMap<>();
private static final Map<String, Deque<Long>> resetRequestTimesByUid = new ConcurrentHashMap<>();
private static final Map<String, String> 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<String, Integer> bonusAttemptsByUid = new ConcurrentHashMap<>();

// 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<String, Deque<Long>> ticketRequestTimesByIp = new ConcurrentHashMap<>();

public static synchronized boolean canRequestTicket(String ip) {
long now = Instant.now().getEpochSecond();
Deque<Long> 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 final byte[] secret = loadSecret();
private static volatile byte[] cachedSecret;

private static class ResetTokenRecord {
private final String token;
Expand All @@ -39,16 +65,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) {
Expand All @@ -58,8 +112,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);
}
Expand Down Expand Up @@ -89,7 +145,8 @@ public static synchronized boolean canIssueResetCode(String uid) {
}

Deque<Long> 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;
}
Expand All @@ -102,6 +159,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));
Comment on lines 170 to 172
Expand Down
106 changes: 106 additions & 0 deletions src/main/java/com/open/spring/mvc/person/FlaskPasswordSync.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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;
}

// The request body carries the new password in plaintext, so this call is only safe if
// it's either loopback (same-box, never hits a real network) or TLS-wrapped. Parses the
// actual host rather than string-prefix-matching flaskUri, since a prefix check like
// startsWith("http://localhost") would wrongly pass a lookalike host such as
// "http://localhost.attacker.com".
private static boolean isSecureTransport(String flaskUri) {
try {
URI parsed = URI.create(flaskUri);
String host = parsed.getHost();
boolean isLoopback = "localhost".equals(host) || "127.0.0.1".equals(host);
boolean isHttps = "https".equals(parsed.getScheme());
return isLoopback || isHttps;
} catch (Exception e) {
return false;
}
}

// 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;
}

if (!isSecureTransport(flaskUri)) {
logger.warn("AUDIT flask_password_sync_skipped uid={} reason=insecure_flask_uri uri={}", uid, flaskUri);
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<String> 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;
}
}
}
Loading