SignupTokenCache.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service.auth;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.Ticker;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.rest.springboot.configuration.CaffeineCacheSource;
import org.entrystore.rest.springboot.model.auth.ConfirmAttemptResult;
import org.entrystore.rest.springboot.model.auth.SignupInfo;
import org.entrystore.rest.springboot.util.CapacityEvictionWarning;
import org.springframework.stereotype.Service;
import org.springframework.util.unit.DataSize;

import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;

/**
 * Holds the pending sign-up and password-reset records, keyed by the one-time token mailed to the
 * user, until the user confirms or the record's own {@link SignupInfo#getExpirationDate() deadline}
 * passes (set by {@code AuthService}, currently 24 hours). Every mutation runs under one lock so
 * {@link #confirmAttempt} can verify, count and remove atomically; a read takes the lock only to
 * drop a record it finds past its deadline (see {@code pastDeadline}).
 *
 * <p>Tokens are minted by anonymous requests whose caller strings (names, redirect URLs, at most 50
 * {@code custom_*} properties) are bounded only by the 32 KB request cap, so {@code maximumWeight} caps the
 * estimated bytes held (see {@link #weigh}) rather than the entry count, and {@link CapacityEvictionWarning}
 * reports when the cap bites. Caffeine evicts by W-TinyLFU, not by age: flood and legitimate tokens are both
 * write-once, so once the cache is full a newly minted legitimate token is evicted before its deadline while
 * flood entries persist to theirs. A flood entry weighs at most ~40 KB (~70 KB with UTF-16 strings), so at the
 * default sign-up rate limit (10 per IP per hour, 24 h TTL) about 8 source IPs fill the cache; the WARN is the
 * signal to look for one.
 */
@Slf4j
@Service
public class SignupTokenCache implements CaffeineCacheSource {

	// Estimated bytes: ~100k typical tokens (~1.3 KB each), but only ~2k-3k flood entries (see class Javadoc).
	static final DataSize MAX_WEIGHT = DataSize.ofMegabytes(128);

	// Object headers, the Date, the map and the cache node: rough, but keeps tiny entries from weighing ~0.
	static final int ENTRY_OVERHEAD = 1024;

	// HashMap node, two String and two byte[] headers, table slot: else many tiny custom_* members hold heap for free.
	static final int PROPERTY_OVERHEAD = 120;

	private final Object lock = new Object();
	private final Cache<String, SignupInfo> tokens;

	public SignupTokenCache(Ticker ticker) {
		var capacityWarning = new CapacityEvictionWarning(log, "Sign-up/password-reset token",
				MAX_WEIGHT.toMegabytes() + " MB");
		this.tokens = Caffeine.newBuilder()
				.ticker(ticker)
				.maximumWeight(MAX_WEIGHT.toBytes())
				.weigher(SignupTokenCache::weigh)
				// Remaining time is measured on the wall clock, elapsed time on the ticker; a record already
				// past its deadline gets a non-positive duration and expires on insert.
				.expireAfter(Expiry.writing((String token, SignupInfo info) ->
						Duration.between(Instant.now(), info.getExpirationDate().toInstant())))
				.evictionListener(capacityWarning.listener())
				.recordStats()
				.build();
	}

	@Override
	public Map<String, Cache<?, ?>> caffeineCaches() {
		return Map.of("signup-tokens", tokens);
	}

	public void putToken(String token, SignupInfo info) {
		// Without a deadline the record would live until capacity eviction; refuse it at the door.
		Objects.requireNonNull(info.getExpirationDate(), "a pending token must carry an expiration date");
		synchronized (lock) {
			tokens.put(token, info);
		}
	}

	/**
	 * Returns the pending record for {@code token}, or {@code null} when the token is unknown, already
	 * consumed, or past its deadline. The deadline is re-checked on the wall clock because Caffeine's
	 * expiry runs on the ticker (see {@code pastDeadline}); a record found past it is removed here, so
	 * the confirmation-form checks in {@code AuthService}, which read through this method, never honour
	 * a stale token. Does not consume the token; a confirmation uses {@link #consumeToken} or
	 * {@link #confirmAttempt}.
	 */
	public SignupInfo getTokenValue(String token) {
		if (token == null) {
			return null;
		}
		SignupInfo info = tokens.getIfPresent(token);
		if (info != null && pastDeadline(info)) {
			removeToken(token);
			return null;
		}
		return info;
	}

	/**
	 * Removes and returns the pending record for {@code token}, or returns {@code null} when the token is
	 * unknown, already consumed, or past its deadline. The atomic remove guarantees that of two concurrent
	 * legacy confirmations of the same link only one gets the record and applies it.
	 */
	public SignupInfo consumeToken(String token) {
		if (token == null) {
			return null;
		}
		SignupInfo info;
		synchronized (lock) {
			info = tokens.asMap().remove(token);
		}
		return (info == null || pastDeadline(info)) ? null : info;
	}

	public void removeToken(String token) {
		synchronized (lock) {
			tokens.invalidate(token);
		}
	}

	public void removeAllTokens(String userEmail) {
		synchronized (lock) {
			tokens.asMap().entrySet().removeIf(entry -> userEmail.equals(entry.getValue().getEmail()));
		}
	}

	/**
	 * Atomically verifies a credential-confirmation attempt against the pending record for {@code token}.
	 * Verification, the failed-attempt count, and token removal all happen under the cache lock so that
	 * parallel attempts cannot race past the limit (the lock-out invariant this feature relies on). The
	 * {@code credentialsValid} predicate runs inside the lock — for sign-up it is a PBKDF2 password check,
	 * for password reset a plain email comparison. Holding the lock across it serializes confirmations;
	 * that work is bounded because the predicate runs only for a found, non-expired token and is capped at
	 * {@code maxAttempts} strikes per token. This method does not itself throttle by IP: callers must
	 * rate-limit the confirm endpoint so a single token cannot be hammered with attempts — the
	 * {@code AuthService.confirmSignup} / {@code confirmPassword} callers acquire a per-IP permit before
	 * invoking it.
	 *
	 * <ul>
	 *   <li>match → the token is removed and {@link ConfirmAttemptResult.Status#VALID} is returned with the record</li>
	 *   <li>mismatch below the limit → the attempt counter is incremented and {@link ConfirmAttemptResult.Status#INVALID_CREDENTIALS} is returned</li>
	 *   <li>mismatch reaching the limit → the token is removed and {@link ConfirmAttemptResult.Status#TOKEN_INVALIDATED} is returned</li>
	 *   <li>no record (unknown or expired) → {@link ConfirmAttemptResult.Status#TOKEN_NOT_FOUND}</li>
	 * </ul>
	 */
	public ConfirmAttemptResult confirmAttempt(String token, Predicate<SignupInfo> credentialsValid, int maxAttempts) {
		synchronized (lock) {
			// A missing token field on the confirm request arrives here as null; treat it as not-found
			// rather than letting the cache's null-key check throw and surface as a 500.
			SignupInfo info = (token == null) ? null : tokens.getIfPresent(token);
			if (info == null) {
				return ConfirmAttemptResult.tokenNotFound();
			}
			// Wall-clock re-check over the ticker-based Caffeine expiry, see pastDeadline.
			if (pastDeadline(info)) {
				tokens.invalidate(token);
				return ConfirmAttemptResult.tokenNotFound();
			}
			if (credentialsValid.test(info)) {
				tokens.invalidate(token);
				return ConfirmAttemptResult.valid(info);
			}
			int attempts = info.recordFailedConfirmation();
			if (attempts >= maxAttempts) {
				tokens.invalidate(token);
				return ConfirmAttemptResult.tokenInvalidated();
			}
			return ConfirmAttemptResult.invalidCredentials(maxAttempts - attempts);
		}
	}

	/**
	 * Whether the record's wall-clock deadline has passed. Caffeine fixes each entry's expiry at insert
	 * and measures elapsed time on the ticker ({@code System.nanoTime}, which is monotonic and does not
	 * advance while the host or VM is suspended), so after a suspend an entry can outlive its deadline
	 * in the cache. The deadline the user was promised is the wall-clock one, so every read path
	 * re-checks it and drops the record once it has passed.
	 */
	private static boolean pastDeadline(SignupInfo info) {
		return info.getExpirationDate().before(new Date());
	}

	/**
	 * Estimated bytes held by one entry: {@link #ENTRY_OVERHEAD}, {@link #PROPERTY_OVERHEAD} per custom property,
	 * plus the caller strings it carries.
	 */
	static int weigh(String token, SignupInfo info) {
		int properties = info.getCustomProperties() == null ? 0 : info.getCustomProperties().size();
		return ENTRY_OVERHEAD + token.length() + PROPERTY_OVERHEAD * properties + info.estimatedSize();
	}
}