diff --git a/orcid-utils/pom.xml b/orcid-utils/pom.xml index 54e95608b5..9a20ff223c 100644 --- a/orcid-utils/pom.xml +++ b/orcid-utils/pom.xml @@ -140,8 +140,8 @@ - com.amazonaws - aws-java-sdk-sns + software.amazon.awssdk + pinpointsmsvoicev2 com.googlecode.libphonenumber diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/AwsNotifySmsSender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/AwsNotifySmsSender.java new file mode 100644 index 0000000000..4b66ef9914 --- /dev/null +++ b/orcid-utils/src/main/java/org/orcid/utils/sms/AwsNotifySmsSender.java @@ -0,0 +1,138 @@ +package org.orcid.utils.sms; + +import java.util.Collections; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.pinpointsmsvoicev2.PinpointSmsVoiceV2Client; +import software.amazon.awssdk.services.pinpointsmsvoicev2.PinpointSmsVoiceV2ClientBuilder; +import software.amazon.awssdk.services.pinpointsmsvoicev2.model.MessageFeedbackStatus; +import software.amazon.awssdk.services.pinpointsmsvoicev2.model.SendNotifyTextMessageRequest; +import software.amazon.awssdk.services.pinpointsmsvoicev2.model.SendNotifyTextMessageResponse; + +/** + * Sends verification codes through AWS End User Messaging Notify ({@code pinpoint-sms-voice-v2} namespace). Notify only + * delivers a templated message; ORCID supplies the code as the {@code code} template variable and verifies it locally. + */ +@Component +public class AwsNotifySmsSender implements VerificationCodeSender { + + public static final String PROVIDER = "aws"; + + @Value("${org.orcid.sms.aws.region:us-east-2}") + private String region; + + @Value("${org.orcid.sms.aws.accessKey:}") + private String accessKey; + + @Value("${org.orcid.sms.aws.secretKey:}") + private String secretKey; + + @Value("${org.orcid.sms.aws.notifyConfigurationId:}") + private String notifyConfigurationId; + + // AWS-managed per-language code-verification templates. The -001 series supports the US (-005/-006 do not). + // {{brandName}} is system-managed (taken from the Notify configuration's registered brand) and must NOT be sent + // as a template variable; only {{code}} is. Keyed by ORCID UI language code (see LANGUAGE_MENU_OPTIONS in + // orcid-angular's environment files); any language ORCID adds before a matching template exists falls back to + // English. + private static final String TEMPLATE_ENGLISH = "notify-code-verification-english-001"; + + private static final Map TEMPLATES_BY_LANGUAGE = Map.ofEntries( + Map.entry("ar", "notify-code-verification-arabic-001"), + Map.entry("cs", "notify-code-verification-czech-001"), + Map.entry("de", "notify-code-verification-german-001"), + Map.entry("en", TEMPLATE_ENGLISH), + Map.entry("es", "notify-code-verification-spanish-001"), + Map.entry("fr", "notify-code-verification-french-001"), + Map.entry("it", "notify-code-verification-italian-001"), + Map.entry("ja", "notify-code-verification-japanese-001"), + Map.entry("ko", "notify-code-verification-korean-001"), + Map.entry("pl", "notify-code-verification-polish-001"), + Map.entry("pt", "notify-code-verification-portuguese-portugal-001"), + Map.entry("ru", "notify-code-verification-russian-001"), + Map.entry("tr", "notify-code-verification-turkish-001"), + // AWS has no separate Traditional Chinese template; Simplified covers both zh-CN and zh-TW. + Map.entry("zh", "notify-code-verification-chinese-simplified-001")); + + @Value("${org.orcid.sms.aws.notifyCodeVariable:code}") + private String codeVariable; + + private PinpointSmsVoiceV2Client client; + + @Override + public String getProvider() { + return PROVIDER; + } + + @Override + public SmsSendResult sendCode(String toE164Number, String code, String locale) { + if (StringUtils.isBlank(notifyConfigurationId)) { + return SmsSendResult.failure(PROVIDER, "AWS_NOTIFY_NOT_CONFIGURED", + "AWS Notify configuration id is required; set org.orcid.sms.aws.notifyConfigurationId"); + } + try { + SendNotifyTextMessageRequest request = SendNotifyTextMessageRequest.builder() + .notifyConfigurationId(notifyConfigurationId) + .destinationPhoneNumber(toE164Number) + .templateId(resolveTemplateId(locale)) + .templateVariables(Collections.singletonMap(codeVariable, code)) + .messageFeedbackEnabled(true) + .build(); + SendNotifyTextMessageResponse response = getClient().sendNotifyTextMessage(request); + return SmsSendResult.success(PROVIDER, response.messageId(), "SENT"); + } catch (Exception e) { + return SmsSendResult.failure(PROVIDER, e.getClass().getSimpleName(), e.getMessage()); + } + } + + @Override + public SmsSendResult reportResult(String toE164Number, String code, String providerMessageId, boolean approved) { + if (StringUtils.isBlank(providerMessageId)) { + return SmsSendResult.success(PROVIDER, providerMessageId, approved ? "approved" : "denied"); + } + try { + getClient().putMessageFeedback(r -> r.messageId(providerMessageId) + .messageFeedbackStatus(approved ? MessageFeedbackStatus.RECEIVED : MessageFeedbackStatus.FAILED)); + return SmsSendResult.success(PROVIDER, providerMessageId, approved ? "approved" : "denied"); + } catch (Exception e) { + return SmsSendResult.failure(PROVIDER, e.getClass().getSimpleName(), e.getMessage()); + } + } + + /** + * Maps the caller's UI locale to the matching localized template, falling back to the English template for any + * language without an AWS-managed template. Only the language subtag matters ({@code es-419} → Spanish, + * {@code zh-TW} → the shared Chinese template). + */ + private static String resolveTemplateId(String locale) { + String language = StringUtils.isBlank(locale) ? "" + : StringUtils.substringBefore(StringUtils.substringBefore(locale.trim(), "-"), "_").toLowerCase(Locale.ROOT); + return TEMPLATES_BY_LANGUAGE.getOrDefault(language, TEMPLATE_ENGLISH); + } + + private PinpointSmsVoiceV2Client getClient() { + if (client == null) { + PinpointSmsVoiceV2ClientBuilder builder = PinpointSmsVoiceV2Client.builder().region(Region.of(region)); + if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) { + builder.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))); + } else { + builder.credentialsProvider(DefaultCredentialsProvider.create()); + } + client = builder.build(); + } + return client; + } + + void setClient(PinpointSmsVoiceV2Client client) { + this.client = client; + } +} diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/AwsSnsSmsSender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/AwsSnsSmsSender.java deleted file mode 100644 index d61bff8263..0000000000 --- a/orcid-utils/src/main/java/org/orcid/utils/sms/AwsSnsSmsSender.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.orcid.utils.sms; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; -import com.amazonaws.regions.Regions; -import com.amazonaws.services.sns.AmazonSNS; -import com.amazonaws.services.sns.AmazonSNSClientBuilder; -import com.amazonaws.services.sns.model.PublishRequest; -import com.amazonaws.services.sns.model.PublishResult; - -@Component -public class AwsSnsSmsSender implements SmsSender { - - public static final String PROVIDER = "aws"; - - @Value("${org.orcid.sms.aws.region:us-east-2}") - private String region; - - @Value("${org.orcid.sms.aws.accessKey:}") - private String accessKey; - - @Value("${org.orcid.sms.aws.secretKey:}") - private String secretKey; - - private AmazonSNS amazonSNS; - - @Override - public String getProvider() { - return PROVIDER; - } - - @Override - public SmsSendResult send(SmsMessage smsMessage) { - try { - PublishResult result = getAmazonSNS().publish(new PublishRequest().withPhoneNumber(smsMessage.getTo()).withMessage(smsMessage.getBody())); - return SmsSendResult.success(PROVIDER, result.getMessageId(), "SENT"); - } catch (Exception e) { - return SmsSendResult.failure(PROVIDER, e.getClass().getSimpleName(), e.getMessage()); - } - } - - private AmazonSNS getAmazonSNS() { - if (amazonSNS == null) { - AmazonSNSClientBuilder builder = AmazonSNSClientBuilder.standard().withRegion(Regions.fromName(region)); - if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) { - builder.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey))); - } else { - builder.withCredentials(DefaultAWSCredentialsProviderChain.getInstance()); - } - amazonSNS = builder.build(); - } - return amazonSNS; - } - - void setAmazonSNS(AmazonSNS amazonSNS) { - this.amazonSNS = amazonSNS; - } -} diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/SmsMessage.java b/orcid-utils/src/main/java/org/orcid/utils/sms/SmsMessage.java deleted file mode 100644 index 3085a9576c..0000000000 --- a/orcid-utils/src/main/java/org/orcid/utils/sms/SmsMessage.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.orcid.utils.sms; - -public class SmsMessage { - - private final String to; - private final String body; - - public SmsMessage(String to, String body) { - this.to = to; - this.body = body; - } - - public String getTo() { - return to; - } - - public String getBody() { - return body; - } -} diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/SmsSender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/SmsSender.java deleted file mode 100644 index ab409e11d8..0000000000 --- a/orcid-utils/src/main/java/org/orcid/utils/sms/SmsSender.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.orcid.utils.sms; - -public interface SmsSender { - - String getProvider(); - - SmsSendResult send(SmsMessage smsMessage); -} diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/TwilioSmsSender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/TwilioSmsSender.java deleted file mode 100644 index 8cd0589808..0000000000 --- a/orcid-utils/src/main/java/org/orcid/utils/sms/TwilioSmsSender.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.orcid.utils.sms; - -import javax.annotation.PostConstruct; - -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import com.twilio.Twilio; -import com.twilio.rest.api.v2010.account.Message; -import com.twilio.type.PhoneNumber; - -@Component -public class TwilioSmsSender implements SmsSender { - - public static final String PROVIDER = "twilio"; - - @Value("${org.orcid.sms.twilio.accountSid:}") - private String accountSid; - - @Value("${org.orcid.sms.twilio.authToken:}") - private String authToken; - - @Value("${org.orcid.sms.twilio.fromNumber:}") - private String fromNumber; - - @Value("${org.orcid.sms.twilio.messagingServiceSid:}") - private String messagingServiceSid; - - @Override - public String getProvider() { - return PROVIDER; - } - - @PostConstruct - public void initTwilioClient() { - if (StringUtils.isNotBlank(accountSid) && StringUtils.isNotBlank(authToken)) { - Twilio.init(accountSid, authToken); - } - } - - @Override - public SmsSendResult send(SmsMessage smsMessage) { - if (StringUtils.isBlank(accountSid) || StringUtils.isBlank(authToken)) { - return SmsSendResult.failure(PROVIDER, "TWILIO_NOT_CONFIGURED", "Twilio account SID and auth token are required"); - } - - try { - Message message; - if (StringUtils.isNotBlank(messagingServiceSid)) { - message = Message.creator(new PhoneNumber(smsMessage.getTo()), messagingServiceSid, smsMessage.getBody()).create(); - } else { - if (StringUtils.isBlank(fromNumber)) { - return SmsSendResult.failure(PROVIDER, "TWILIO_SENDER_NOT_CONFIGURED", - "Twilio sender number is required; set org.orcid.sms.twilio.fromNumber"); - } - message = Message.creator(new PhoneNumber(smsMessage.getTo()), new PhoneNumber(fromNumber), smsMessage.getBody()).create(); - } - return SmsSendResult.success(PROVIDER, message.getSid(), message.getStatus() == null ? "SENT" : message.getStatus().toString()); - } catch (Exception e) { - return SmsSendResult.failure(PROVIDER, e.getClass().getSimpleName(), e.getMessage()); - } - } -} diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/TwilioVerifySender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/TwilioVerifySender.java new file mode 100644 index 0000000000..a87df2f19e --- /dev/null +++ b/orcid-utils/src/main/java/org/orcid/utils/sms/TwilioVerifySender.java @@ -0,0 +1,122 @@ +package org.orcid.utils.sms; + +import javax.annotation.PostConstruct; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.twilio.Twilio; +import com.twilio.rest.verify.v2.service.Verification; +import com.twilio.rest.verify.v2.service.VerificationCheck; +import com.twilio.rest.verify.v2.service.VerificationCreator; + +/** + * Sends verification codes through Twilio Verify. ORCID generates the code and passes it as a custom verification code + * so the same application-owned code is used across every provider. Verification is authoritative in ORCID; the + * {@link #reportResult} call performs the Twilio VerificationCheck, which both closes Twilio's verification and provides + * the feedback Twilio requires when custom codes are used. + * + * @see Twilio custom verification codes + */ +@Component +public class TwilioVerifySender implements VerificationCodeSender { + + public static final String PROVIDER = "twilio"; + + private static final String CHANNEL_SMS = "sms"; + + @Value("${org.orcid.sms.twilio.accountSid:}") + private String accountSid; + + @Value("${org.orcid.sms.twilio.authToken:}") + private String authToken; + + @Value("${org.orcid.sms.twilio.verifyServiceSid:}") + private String verifyServiceSid; + + @Override + public String getProvider() { + return PROVIDER; + } + + @PostConstruct + public void initTwilioClient() { + if (StringUtils.isNotBlank(accountSid) && StringUtils.isNotBlank(authToken)) { + Twilio.init(accountSid, authToken); + } + } + + @Override + public SmsSendResult sendCode(String toE164Number, String code, String locale) { + SmsSendResult configError = configError(); + if (configError != null) { + return configError; + } + try { + VerificationCreator creator = Verification + .creator(verifyServiceSid, toE164Number, CHANNEL_SMS) + .setCustomCode(code); + String twilioLocale = normalizeLocale(locale); + if (StringUtils.isNotBlank(twilioLocale)) { + creator.setLocale(twilioLocale); + } + Verification verification = creator.create(); + return SmsSendResult.success(PROVIDER, verification.getSid(), + verification.getStatus() == null ? "pending" : verification.getStatus()); + } catch (Exception e) { + return SmsSendResult.failure(PROVIDER, e.getClass().getSimpleName(), e.getMessage()); + } + } + + @Override + public SmsSendResult reportResult(String toE164Number, String code, String providerMessageId, boolean approved) { + SmsSendResult configError = configError(); + if (configError != null) { + return configError; + } + // Twilio only accepts a VerificationCheck for the approved case; a denied local check leaves the Twilio + // verification to expire on its own. + if (!approved) { + return SmsSendResult.success(PROVIDER, providerMessageId, "denied"); + } + try { + VerificationCheck check = VerificationCheck.creator(verifyServiceSid) + .setTo(toE164Number) + .setCode(code) + .create(); + return SmsSendResult.success(PROVIDER, providerMessageId, + check.getStatus() == null ? "approved" : check.getStatus()); + } catch (Exception e) { + return SmsSendResult.failure(PROVIDER, e.getClass().getSimpleName(), e.getMessage()); + } + } + + /** + * Twilio's Verify locale is the lowercase language subtag except for region-specific translations (zh-CN, zh-HK, + * pt-BR, en-GB), which are passed through. Twilio falls back to English for unsupported values. + */ + private static String normalizeLocale(String locale) { + if (StringUtils.isBlank(locale)) { + return null; + } + String normalized = locale.trim().replace('_', '-'); + String language = StringUtils.substringBefore(normalized, "-").toLowerCase(java.util.Locale.ROOT); + if ("zh".equals(language) || "pt".equals(language) || "en".equals(language)) { + String region = StringUtils.substringAfter(normalized, "-").toUpperCase(java.util.Locale.ROOT); + return StringUtils.isBlank(region) ? language : language + "-" + region; + } + return language; + } + + private SmsSendResult configError() { + if (StringUtils.isBlank(accountSid) || StringUtils.isBlank(authToken)) { + return SmsSendResult.failure(PROVIDER, "TWILIO_NOT_CONFIGURED", "Twilio account SID and auth token are required"); + } + if (StringUtils.isBlank(verifyServiceSid)) { + return SmsSendResult.failure(PROVIDER, "TWILIO_VERIFY_NOT_CONFIGURED", + "Twilio Verify service SID is required; set org.orcid.sms.twilio.verifyServiceSid"); + } + return null; + } +} diff --git a/orcid-utils/src/main/java/org/orcid/utils/sms/VerificationCodeSender.java b/orcid-utils/src/main/java/org/orcid/utils/sms/VerificationCodeSender.java new file mode 100644 index 0000000000..2825cfd5c3 --- /dev/null +++ b/orcid-utils/src/main/java/org/orcid/utils/sms/VerificationCodeSender.java @@ -0,0 +1,30 @@ +package org.orcid.utils.sms; + +/** + * Delivers an application-generated verification code through a managed provider (AWS End User Messaging Notify or + * Twilio Verify). The code is always generated and verified by ORCID; the provider only handles templated delivery, + * routing, and fraud protection. + */ +public interface VerificationCodeSender { + + String getProvider(); + + /** + * Sends the supplied verification code to the given E.164 number through the provider's managed template. + * + * @param locale the recipient's UI locale (BCP 47, e.g. {@code es} or {@code zh-CN}); providers use it to pick a + * localized message template and fall back to English when the language is not available + */ + SmsSendResult sendCode(String toE164Number, String code, String locale); + + /** + * Reports the outcome of the local verification back to the provider. This closes the provider-side verification + * loop and satisfies the feedback requirement that both Twilio Verify (custom codes) and AWS Notify expect. The + * default implementation is a no-op success for providers that do not need feedback. + * + * @param providerMessageId the id returned by {@link #sendCode(String, String, String)} (Twilio verification SID / AWS message id) + */ + default SmsSendResult reportResult(String toE164Number, String code, String providerMessageId, boolean approved) { + return SmsSendResult.success(getProvider(), providerMessageId, approved ? "approved" : "denied"); + } +} diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/InMemoryVerificationCodeStore.java b/orcid-web/src/main/java/org/orcid/frontend/sms/InMemoryVerificationCodeStore.java new file mode 100644 index 0000000000..7ee256ca38 --- /dev/null +++ b/orcid-web/src/main/java/org/orcid/frontend/sms/InMemoryVerificationCodeStore.java @@ -0,0 +1,42 @@ +package org.orcid.frontend.sms; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +/** + * POC-ONLY {@link VerificationCodeStore}. Codes for BOTH providers (AWS Notify and Twilio Verify) are held in this + * JVM's heap, which is only correct on a single node: codes are lost on restart/redeploy, are invisible to other + * cluster nodes (a verify request routed to a different node fails with CODE_NOT_FOUND), and the attempt counter is + * per-JVM so the maxAttempts limit is not enforced cluster-wide. Do not ship this beyond the POC — replace it with a + * Redis-backed implementation per the migration contract on {@link VerificationCodeStore}. + */ +@Component +public class InMemoryVerificationCodeStore implements VerificationCodeStore { + + private final Map entries = new ConcurrentHashMap(); + + @Override + public void save(String phoneNumber, VerificationCodeEntry entry) { + entries.put(phoneNumber, entry); + } + + @Override + public VerificationCodeEntry get(String phoneNumber) { + VerificationCodeEntry entry = entries.get(phoneNumber); + if (entry == null) { + return null; + } + if (entry.isExpired(System.currentTimeMillis())) { + entries.remove(phoneNumber, entry); + return null; + } + return entry; + } + + @Override + public void remove(String phoneNumber) { + entries.remove(phoneNumber); + } +} diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocRequest.java b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocRequest.java index 150447a108..f7502160dd 100644 --- a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocRequest.java +++ b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocRequest.java @@ -1,10 +1,14 @@ package org.orcid.frontend.sms; +/** + * Request to start a phone verification: the provider delivers an ORCID-generated code to this number. + */ public class SmsPocRequest { private String phoneNumber; - private String message; private String provider; + // The caller's UI locale (BCP 47, e.g. "es" or "zh-CN"); used to pick a localized message template. + private String locale; public String getPhoneNumber() { return phoneNumber; @@ -14,14 +18,6 @@ public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - public String getProvider() { return provider; } @@ -29,4 +25,12 @@ public String getProvider() { public void setProvider(String provider) { this.provider = provider; } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } } diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocResponse.java b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocResponse.java index e9b04570ea..cc63ceabf8 100644 --- a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocResponse.java +++ b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocResponse.java @@ -7,6 +7,7 @@ public class SmsPocResponse { private String providerMessageId; private String normalizedPhoneNumber; private String status; + private Boolean verified; private String errorCode; private String errorMessage; @@ -20,6 +21,22 @@ public static SmsPocResponse success(String provider, String providerMessageId, return response; } + /** + * Result of a verification check: {@code success} means the request was processed; {@code verified} carries whether + * the supplied code matched. + */ + public static SmsPocResponse verificationResult(String provider, String providerMessageId, String normalizedPhoneNumber, + boolean verified, String status) { + SmsPocResponse response = new SmsPocResponse(); + response.success = true; + response.provider = provider; + response.providerMessageId = providerMessageId; + response.normalizedPhoneNumber = normalizedPhoneNumber; + response.verified = verified; + response.status = status; + return response; + } + public static SmsPocResponse failure(String provider, String normalizedPhoneNumber, String errorCode, String errorMessage) { SmsPocResponse response = new SmsPocResponse(); response.success = false; @@ -51,6 +68,10 @@ public String getStatus() { return status; } + public Boolean getVerified() { + return verified; + } + public String getErrorCode() { return errorCode; } diff --git a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocService.java b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocService.java index af18c0227d..4a551eb120 100644 --- a/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocService.java +++ b/orcid-web/src/main/java/org/orcid/frontend/sms/SmsPocService.java @@ -1,5 +1,6 @@ package org.orcid.frontend.sms; +import java.security.SecureRandom; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -7,22 +8,29 @@ import org.apache.commons.lang3.StringUtils; import org.orcid.utils.phone.PhoneNumberValidationResult; import org.orcid.utils.phone.PhoneNumberValidator; -import org.orcid.utils.sms.SmsMessage; import org.orcid.utils.sms.SmsSendResult; -import org.orcid.utils.sms.SmsSender; +import org.orcid.utils.sms.VerificationCodeSender; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +/** + * Drives phone verification through managed providers. ORCID generates the verification code, stores it, and confirms + * it; the provider (AWS End User Messaging Notify or Twilio Verify) only delivers the code through its managed, + * templated, fraud-protected pipeline. + */ @Component public class SmsPocService { - private static final int MAX_MESSAGE_LENGTH = 1600; + private static final SecureRandom RANDOM = new SecureRandom(); @Autowired private PhoneNumberValidator phoneNumberValidator; - private Map smsSendersByProvider = new HashMap(); + @Autowired + private VerificationCodeStore verificationCodeStore; + + private Map sendersByProvider = new HashMap(); @Value("${org.orcid.sms.provider:aws}") private String provider; @@ -33,29 +41,34 @@ public class SmsPocService { @Value("${org.orcid.sms.regexFilter:}") private String regexFilter; + @Value("${org.orcid.sms.code.length:6}") + private int codeLength; + + @Value("${org.orcid.sms.code.ttlSeconds:300}") + private int codeTtlSeconds; + + @Value("${org.orcid.sms.code.maxAttempts:5}") + private int maxAttempts; + @Autowired - public void setSmsSenders(List smsSenders) { - smsSendersByProvider.clear(); - if (smsSenders != null) { - for (SmsSender smsSender : smsSenders) { - smsSendersByProvider.put(StringUtils.lowerCase(smsSender.getProvider()), smsSender); + public void setSenders(List 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} - com.amazonaws - aws-java-sdk-sns - ${aws.version} + software.amazon.awssdk + pinpointsmsvoicev2 + ${awssdk.version} com.googlecode.libphonenumber