senders) {
+ sendersByProvider.clear();
+ if (senders != null) {
+ for (VerificationCodeSender sender : senders) {
+ sendersByProvider.put(StringUtils.lowerCase(sender.getProvider()), sender);
}
}
}
- public SmsPocResponse send(SmsPocRequest request) {
- String selectedProvider = StringUtils.lowerCase(StringUtils.defaultIfBlank(
- request != null ? request.getProvider() : null,
- StringUtils.defaultIfBlank(provider, "aws")));
+ /**
+ * Generates a verification code, sends it through the selected managed provider, and stores it for later
+ * confirmation. The code itself is never returned to the caller.
+ */
+ public SmsPocResponse startVerification(SmsPocRequest request) {
+ String selectedProvider = resolveProvider(request != null ? request.getProvider() : null);
if (request == null) {
return SmsPocResponse.failure(selectedProvider, null, "INVALID_REQUEST", "Request body is required");
}
- if (StringUtils.isBlank(request.getMessage())) {
- return SmsPocResponse.failure(selectedProvider, null, "INVALID_MESSAGE", "Message is required");
- }
- if (request.getMessage().length() > MAX_MESSAGE_LENGTH) {
- return SmsPocResponse.failure(selectedProvider, null, "INVALID_MESSAGE", "Message exceeds " + MAX_MESSAGE_LENGTH + " characters");
- }
PhoneNumberValidationResult validationResult = phoneNumberValidator.validate(request.getPhoneNumber(), defaultRegion);
if (!validationResult.isValid()) {
@@ -64,25 +77,117 @@ public SmsPocResponse send(SmsPocRequest request) {
String normalizedPhoneNumber = validationResult.getE164Number();
if (StringUtils.isNotBlank(regexFilter) && !normalizedPhoneNumber.matches(regexFilter)) {
- return SmsPocResponse.failure(selectedProvider, normalizedPhoneNumber, "SMS_RECIPIENT_NOT_ALLOWED", "Phone number is not allowed by SMS safety filter");
+ return SmsPocResponse.failure(selectedProvider, normalizedPhoneNumber, "SMS_RECIPIENT_NOT_ALLOWED",
+ "Phone number is not allowed by SMS safety filter");
+ }
+
+ VerificationCodeSender sender = sendersByProvider.get(selectedProvider);
+ if (sender == null) {
+ return SmsPocResponse.failure(selectedProvider, normalizedPhoneNumber, "SMS_PROVIDER_NOT_CONFIGURED",
+ "SMS provider is not configured: " + selectedProvider);
+ }
+
+ String code = generateCode();
+ SmsSendResult result = sender.sendCode(normalizedPhoneNumber, code, sanitizeLocale(request.getLocale()));
+ if (!result.isSuccess()) {
+ return SmsPocResponse.failure(result.getProvider(), normalizedPhoneNumber, result.getErrorCode(), result.getErrorMessage());
+ }
+
+ long expiresAt = System.currentTimeMillis() + (codeTtlSeconds * 1000L);
+ verificationCodeStore.save(normalizedPhoneNumber,
+ new VerificationCodeEntry(code, result.getProvider(), result.getProviderMessageId(), expiresAt));
+ return SmsPocResponse.success(result.getProvider(), result.getProviderMessageId(), normalizedPhoneNumber, result.getStatus());
+ }
+
+ /**
+ * Confirms a code against the stored value. ORCID is authoritative; the matching provider is then told the outcome
+ * so its verification record is closed (Twilio VerificationCheck / AWS PutMessageFeedback).
+ */
+ public SmsPocResponse checkVerification(SmsVerificationCheckRequest request) {
+ if (request == null) {
+ return SmsPocResponse.failure(provider, null, "INVALID_REQUEST", "Request body is required");
+ }
+ if (StringUtils.isBlank(request.getCode())) {
+ return SmsPocResponse.failure(provider, null, "INVALID_CODE", "Code is required");
+ }
+
+ PhoneNumberValidationResult validationResult = phoneNumberValidator.validate(request.getPhoneNumber(), defaultRegion);
+ if (!validationResult.isValid()) {
+ return SmsPocResponse.failure(provider, null, "INVALID_PHONE_NUMBER", validationResult.getErrorMessage());
+ }
+ String normalizedPhoneNumber = validationResult.getE164Number();
+
+ VerificationCodeEntry entry = verificationCodeStore.get(normalizedPhoneNumber);
+ if (entry == null) {
+ return SmsPocResponse.failure(provider, normalizedPhoneNumber, "CODE_NOT_FOUND",
+ "No active verification code for this number; it may have expired");
+ }
+
+ if (entry.incrementAttempts() > maxAttempts) {
+ verificationCodeStore.remove(normalizedPhoneNumber);
+ return SmsPocResponse.failure(entry.getProvider(), normalizedPhoneNumber, "TOO_MANY_ATTEMPTS",
+ "Maximum verification attempts exceeded");
+ }
+
+ VerificationCodeSender sender = sendersByProvider.get(entry.getProvider());
+ boolean matches = constantTimeEquals(entry.getCode(), request.getCode().trim());
+ if (!matches) {
+ return SmsPocResponse.verificationResult(entry.getProvider(), entry.getProviderMessageId(), normalizedPhoneNumber,
+ false, "NOT_VERIFIED");
+ }
+
+ verificationCodeStore.remove(normalizedPhoneNumber);
+ // Best-effort provider feedback; a feedback failure does not invalidate a code ORCID already confirmed.
+ if (sender != null) {
+ sender.reportResult(normalizedPhoneNumber, entry.getCode(), entry.getProviderMessageId(), true);
}
+ return SmsPocResponse.verificationResult(entry.getProvider(), entry.getProviderMessageId(), normalizedPhoneNumber,
+ true, "VERIFIED");
+ }
- SmsSender smsSender = smsSendersByProvider.get(selectedProvider);
- if (smsSender == null) {
- return SmsPocResponse.failure(selectedProvider, normalizedPhoneNumber, "SMS_PROVIDER_NOT_CONFIGURED", "SMS provider is not configured: " + selectedProvider);
+ /**
+ * Accepts only a plausible BCP 47 tag from the caller; anything else is dropped so senders fall back to English.
+ */
+ private static String sanitizeLocale(String locale) {
+ if (StringUtils.isBlank(locale)) {
+ return null;
}
+ String trimmed = locale.trim();
+ return trimmed.matches("[A-Za-z]{2,8}([_-][A-Za-z0-9]{1,8}){0,3}") ? trimmed : null;
+ }
- SmsSendResult result = smsSender.send(new SmsMessage(normalizedPhoneNumber, request.getMessage()));
- if (result.isSuccess()) {
- return SmsPocResponse.success(result.getProvider(), result.getProviderMessageId(), normalizedPhoneNumber, result.getStatus());
+ private String resolveProvider(String requestedProvider) {
+ return StringUtils.lowerCase(StringUtils.defaultIfBlank(requestedProvider, StringUtils.defaultIfBlank(provider, "aws")));
+ }
+
+ private String generateCode() {
+ int length = codeLength > 0 ? codeLength : 6;
+ StringBuilder builder = new StringBuilder(length);
+ for (int i = 0; i < length; i++) {
+ builder.append(RANDOM.nextInt(10));
}
- return SmsPocResponse.failure(result.getProvider(), normalizedPhoneNumber, result.getErrorCode(), result.getErrorMessage());
+ return builder.toString();
+ }
+
+ private static boolean constantTimeEquals(String expected, String actual) {
+ if (expected == null || actual == null || expected.length() != actual.length()) {
+ return false;
+ }
+ int result = 0;
+ for (int i = 0; i < expected.length(); i++) {
+ result |= expected.charAt(i) ^ actual.charAt(i);
+ }
+ return result == 0;
}
void setPhoneNumberValidator(PhoneNumberValidator phoneNumberValidator) {
this.phoneNumberValidator = phoneNumberValidator;
}
+ void setVerificationCodeStore(VerificationCodeStore verificationCodeStore) {
+ this.verificationCodeStore = verificationCodeStore;
+ }
+
void setProvider(String provider) {
this.provider = provider;
}
@@ -94,4 +199,16 @@ void setDefaultRegion(String defaultRegion) {
void setRegexFilter(String regexFilter) {
this.regexFilter = regexFilter;
}
+
+ void setCodeLength(int codeLength) {
+ this.codeLength = codeLength;
+ }
+
+ void setCodeTtlSeconds(int codeTtlSeconds) {
+ this.codeTtlSeconds = codeTtlSeconds;
+ }
+
+ void setMaxAttempts(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ }
}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsVerificationCheckRequest.java b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsVerificationCheckRequest.java
new file mode 100644
index 0000000000..6d959dbd47
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsVerificationCheckRequest.java
@@ -0,0 +1,26 @@
+package org.orcid.frontend.sms;
+
+/**
+ * Request to confirm a previously issued verification code for a phone number.
+ */
+public class SmsVerificationCheckRequest {
+
+ private String phoneNumber;
+ private String code;
+
+ public String getPhoneNumber() {
+ return phoneNumber;
+ }
+
+ public void setPhoneNumber(String phoneNumber) {
+ this.phoneNumber = phoneNumber;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public void setCode(String code) {
+ this.code = code;
+ }
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/VerificationCodeEntry.java b/orcid-web/src/main/java/org/orcid/frontend/sms/VerificationCodeEntry.java
new file mode 100644
index 0000000000..9d0d96fd6f
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/sms/VerificationCodeEntry.java
@@ -0,0 +1,46 @@
+package org.orcid.frontend.sms;
+
+/**
+ * An issued verification code awaiting confirmation. ORCID is the authoritative store of the code; the managed provider
+ * only delivered it.
+ */
+public class VerificationCodeEntry {
+
+ private final String code;
+ private final String provider;
+ private final String providerMessageId;
+ private final long expiresAtEpochMs;
+ private int attempts;
+
+ public VerificationCodeEntry(String code, String provider, String providerMessageId, long expiresAtEpochMs) {
+ this.code = code;
+ this.provider = provider;
+ this.providerMessageId = providerMessageId;
+ this.expiresAtEpochMs = expiresAtEpochMs;
+ this.attempts = 0;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getProvider() {
+ return provider;
+ }
+
+ public String getProviderMessageId() {
+ return providerMessageId;
+ }
+
+ public boolean isExpired(long nowEpochMs) {
+ return nowEpochMs >= expiresAtEpochMs;
+ }
+
+ public int getAttempts() {
+ return attempts;
+ }
+
+ public int incrementAttempts() {
+ return ++attempts;
+ }
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/VerificationCodeStore.java b/orcid-web/src/main/java/org/orcid/frontend/sms/VerificationCodeStore.java
new file mode 100644
index 0000000000..262c87f85f
--- /dev/null
+++ b/orcid-web/src/main/java/org/orcid/frontend/sms/VerificationCodeStore.java
@@ -0,0 +1,27 @@
+package org.orcid.frontend.sms;
+
+/**
+ * Stores issued verification codes keyed by normalized (E.164) phone number until they are confirmed or expire. Every
+ * pending code lives here regardless of the delivering provider — AWS End User Messaging Notify and Twilio Verify
+ * sends both depend on this store, because ORCID (not the provider) is authoritative for code verification.
+ *
+ * Production requirement: moving past the POC means replacing the in-memory implementation with a
+ * Redis-backed one (see {@code org.orcid.core.utils.cache.redis.RedisClient} in orcid-core: {@code set(key, value,
+ * cacheExpiryInSecs)} maps to save+TTL, {@code get}/{@code remove} map directly). The registry runs multi-node, so a
+ * JVM-local store means the verify request only succeeds when it happens to hit the node that sent the code. A
+ * replacement must keep three behaviors: (1) entry expiry equal to {@code org.orcid.sms.code.ttlSeconds}, (2) an
+ * attempt counter that increments atomically across nodes (serialize {@link VerificationCodeEntry} or use a Redis
+ * counter), and (3) round-tripping {@code provider} + {@code providerMessageId} intact — they drive the post-verify
+ * provider feedback (Twilio VerificationCheck / AWS PutMessageFeedback).
+ */
+public interface VerificationCodeStore {
+
+ void save(String phoneNumber, VerificationCodeEntry entry);
+
+ /**
+ * Returns the active entry for the number, or {@code null} if none exists or it has expired.
+ */
+ VerificationCodeEntry get(String phoneNumber);
+
+ void remove(String phoneNumber);
+}
diff --git a/orcid-web/src/main/java/org/orcid/frontend/web/controllers/SmsPocController.java b/orcid-web/src/main/java/org/orcid/frontend/web/controllers/SmsPocController.java
index c65fc08089..06a28f1db2 100644
--- a/orcid-web/src/main/java/org/orcid/frontend/web/controllers/SmsPocController.java
+++ b/orcid-web/src/main/java/org/orcid/frontend/web/controllers/SmsPocController.java
@@ -6,6 +6,7 @@
import org.orcid.frontend.sms.SmsPocRequest;
import org.orcid.frontend.sms.SmsPocResponse;
import org.orcid.frontend.sms.SmsPocService;
+import org.orcid.frontend.sms.SmsVerificationCheckRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -19,8 +20,15 @@ public class SmsPocController {
@Resource
private SmsPocService smsPocService;
+ /** Starts a verification: generates a code and delivers it through the managed provider. */
@RequestMapping(value = "/send.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON)
public @ResponseBody SmsPocResponse send(@RequestBody SmsPocRequest request) {
- return smsPocService.send(request);
+ return smsPocService.startVerification(request);
+ }
+
+ /** Confirms a code previously sent to a phone number. */
+ @RequestMapping(value = "/verify.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON)
+ public @ResponseBody SmsPocResponse verify(@RequestBody SmsVerificationCheckRequest request) {
+ return smsPocService.checkVerification(request);
}
}
diff --git a/orcid-web/src/main/resources/orcid-frontend-security.xml b/orcid-web/src/main/resources/orcid-frontend-security.xml
index 879392d4bc..44c0ecee87 100644
--- a/orcid-web/src/main/resources/orcid-frontend-security.xml
+++ b/orcid-web/src/main/resources/orcid-frontend-security.xml
@@ -393,6 +393,8 @@
access="ROLE_USER"/>
+
9437
9438
1.12.261
+
+ 2.46.21
9.0.30
12.1.1
8.0.0
@@ -938,9 +941,9 @@ the software.
${aws.version}