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.

52 changes: 42 additions & 10 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 @@ -27,7 +29,7 @@ public class ResetCode {
private static final Map<String, Deque<Long>> resetRequestTimesByUid = new ConcurrentHashMap<>();
private static final Map<String, String> lastIssueReasonByUid = new ConcurrentHashMap<>();

private static final byte[] secret = loadSecret();
private static volatile byte[] cachedSecret;

private static class ResetTokenRecord {
private final String token;
Expand All @@ -39,16 +41,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 +88,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
84 changes: 84 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,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<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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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;
}
}
}
9 changes: 9 additions & 0 deletions src/main/java/com/open/spring/mvc/person/Person.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ public class Person extends Submitter implements Comparable<Person> {
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;

// Bumped every time the password actually changes (PersonDetailsService.save, when
// samePassword is false -- the single funnel every reset/update path goes through).
// Embedded in issued JWTs and checked on every /api/** request (JwtTokenUtil); a
// mismatch means the token predates the current password and is rejected, so a
// stolen JWT stops working the moment its owner resets their password instead of
// staying valid for the rest of its 12h lifetime.
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private Long tokenVersion = 0L;

@NotEmpty
@Size(min = 1)
@Column(unique = true, nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ public void save(Person person, Boolean samePassword) {
if (!samePassword) {
// Encode the password only if it's not the same as before
person.setPassword(passwordEncoder.encode(person.getPassword()));
// Invalidates every JWT already issued to this person -- see the field
// comment on Person.tokenVersion.
person.setTokenVersion((person.getTokenVersion() == null ? 0L : person.getTokenVersion()) + 1);
}
personJpaRepository.save(person); // Save the person to the database
}
Expand Down
Loading