diff --git a/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email.ftl b/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email.ftl index 942550a8898..2f115558c56 100644 --- a/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email.ftl +++ b/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email.ftl @@ -1,18 +1,19 @@ <#import "email_macros.ftl" as emailMacros /> Dear ${emailName}, -This is an important message to let you know that you have exceeded our daily Public API usage limit with your integration: +This is an important message to let you know that you have exceeded your daily Public API usage limit with your integration (https://info.orcid.org/ufaqs/what-are-the-api-limits/): Client Name: ${clientName} Client ID: ${clientId} -Please remember that the ORCID Public API is free for non-commercial use by individuals as stated in the Public APIs Terms of Service (https://info.orcid.org/public-client-terms-of-service/). By “non-commercial” we mean that you may not charge any re-use fees for the Public API, and you may not make use of the Public API in connection with any revenue-generating product or service +Please remember that the ORCID Public API is free for non-commercial use by individuals as stated in the Public APIs Terms of Service (https://info.orcid.org/public-client-terms-of-service). By “non-commercial” we mean that you may not charge any re-use fees for the Public API, and you may not make use of the Public API in connection with any revenue-generating product or service. -If you need access to an ORCID API for commercial use, need a higher usage quota, organizational administration of your API credentials, or the ability to write data to or access Trusted Party data in ORCID records, our Member API (https://info.orcid.org/documentation/features/member-api/) is available to ORCID member organizations. +Based on your API usage, we highly recommend you consider becoming an ORCID member for access to our Member API (https://info.orcid.org/documentation/features/member-api/). Not only will it allow you to access a higher Rate Limits and an unrestricted Usage Quota, but you will be able to access Trusted Party data in ORCID records and contribute data to ORCID records from your institutional systems. -To minimize any disruption to your ORCID integration in the future, we would recommend that you reach out to our Engagement Team by replying to this email to discuss our ORCID membership options. +To minimize any disruption to your ORCID integration in the future, we would recommend that you reach out to our Engagement Team by replying to this email to discuss our ORCID membership options. -Warm Regards, + +Warm Regards, ORCID Support Team https://support.orcid.org diff --git a/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email_html.ftl b/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email_html.ftl index f7d4ebca59b..703541df9fc 100644 --- a/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email_html.ftl +++ b/orcid-core/src/main/resources/org/orcid/core/template/papi_rate_limit_email_html.ftl @@ -10,12 +10,12 @@ ORCID.org
Dear ${emailName}, -

This is an important message to let you know that you have exceeded our daily Public API usage limit with your integration:

+

This is an important message to let you know that you have exceeded your daily Public API usage limit with your integration:

Client Name: ${clientName}

Client ID: ${clientId}


-

Please remember that the ORCID Public API is free for non-commercial use by individuals as stated in the Public APIs Terms of Service (https://info.orcid.org/public-client-terms-of-service/). By “non-commercial” we mean that you may not charge any re-use fees for the Public API, and you may not make use of the Public API in connection with any revenue-generating product or service.

-

If you need access to an ORCID API for commercial use, need a higher usage quota, organizational administration of your API credentials, or the ability to write data to or access Trusted Party data in ORCID records, our Member API (https://info.orcid.org/documentation/features/member-api/) is available to ORCID member organizations.

+

Please remember that the ORCID Public API is free for non-commercial use by individuals as stated in the Public APIs Terms of Service. By “non-commercial” we mean that you may not charge any re-use fees for the Public API, and you may not make use of the Public API in connection with any revenue-generating product or service.

+

Based on your API usage, we highly recommend you consider becoming an ORCID member for access to our Member API. Not only will it allow you to access a higher Rate Limits and an unrestricted Usage Quota, but you will be able to access Trusted Party data in ORCID records and contribute data to ORCID records from your institutional systems.

To minimize any disruption to your ORCID integration in the future, we would recommend that you reach out to our Engagement Team by replying to this email to discuss our ORCID membership options.

diff --git a/orcid-pub-web/src/main/java/org/orcid/api/filters/ApiRateLimitFilter.java b/orcid-pub-web/src/main/java/org/orcid/api/filters/ApiRateLimitFilter.java index c4a4dc049ff..6ef9c8f269e 100644 --- a/orcid-pub-web/src/main/java/org/orcid/api/filters/ApiRateLimitFilter.java +++ b/orcid-pub-web/src/main/java/org/orcid/api/filters/ApiRateLimitFilter.java @@ -47,7 +47,7 @@ @Component public class ApiRateLimitFilter extends OncePerRequestFilter { - private static Logger LOG = LoggerFactory.getLogger(ApiRateLimitFilter.class); + private static final Logger LOG = LoggerFactory.getLogger(ApiRateLimitFilter.class); @Autowired private PublicApiDailyRateLimitDao papiRateLimitingDao; @@ -61,9 +61,6 @@ public class ApiRateLimitFilter extends OncePerRequestFilter { @Autowired private MailGunManager mailGunManager; - @Autowired - private ProfileDao profileDao; - @Autowired private OrcidUrlManager orcidUrlManager; @@ -110,11 +107,14 @@ public class ApiRateLimitFilter extends OncePerRequestFilter { + "You can increase your daily quota by registering for and using Public API client credentials " + "(https://info.orcid.org/documentation/integration-guide/registering-a-public-api-client/ )"; - private static final String SUBJECT = "[ORCID] You have exceeded the daily Public API Usage Limit - "; + private static final String SUBJECT = "[ORCID-API] WARNING! You have exceeded the daily Public API Usage Limit - "; - @Value("${org.orcid.papi.rate.limit.fromEmail:notify@notify.orcid.org}") + @Value("${org.orcid.papi.rate.limit.fromEmail:apiusage@orcid.org}") private String FROM_ADDRESS; + @Value("${org.orcid.papi.rate.limit.ccAddress:membersupport@orcid.org}") + private String CC_ADDRESS; + @Override public void afterPropertiesSet() throws ServletException { super.afterPropertiesSet(); @@ -123,7 +123,7 @@ public void afterPropertiesSet() throws ServletException { } @Override - protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) + public void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { LOG.trace("ApiRateLimitFilter starts, rate limit is : " + enableRateLimiting); if (enableRateLimiting) { @@ -197,7 +197,6 @@ private void rateLimitAnonymousRequest(String ipAddress, LocalDate today, HttpSe } return; - } private void rateLimitClientRequest(String clientId, LocalDate today) { @@ -240,30 +239,30 @@ private Map createTemplateParams(String clientId, String clientN private void sendEmail(String clientId, LocalDate requestDate) { ClientDetailsEntity clientDetailsEntity = clientDetailsEntityCacheManager.retrieve(clientId); - ProfileEntity profile = profileDao.find(clientDetailsEntity.getGroupProfileId()); - String emailName = recordNameManager.deriveEmailFriendlyName(profile.getId()); - Map templateParams = this.createTemplateParams(clientId, clientDetailsEntity.getClientName(), emailName, profile.getId()); + String memberId = clientDetailsEntity.getGroupProfileId(); + String emailName = recordNameManager.deriveEmailFriendlyName(memberId); + Map templateParams = this.createTemplateParams(clientId, clientDetailsEntity.getClientName(), emailName, memberId); // Generate body from template String body = templateManager.processTemplate("papi_rate_limit_email.ftl", templateParams); // Generate html from template String html = templateManager.processTemplate("papi_rate_limit_email_html.ftl", templateParams); - String email = emailManager.findPrimaryEmail(profile.getId()).getEmail(); + String email = emailManager.findPrimaryEmail(memberId).getEmail(); LOG.info("from address={}", FROM_ADDRESS); LOG.info("text email={}", body); LOG.info("html email={}", html); if (enablePanoplyPapiExceededRateInProduction) { PanoplyPapiDailyRateExceededItem item = new PanoplyPapiDailyRateExceededItem(); item.setClientId(clientId); - item.setOrcid(profile.getId()); + item.setOrcid(memberId); item.setEmail(email); item.setRequestDate(requestDate); setPapiRateExceededItemInPanoply(item); } // Send the email - boolean mailSent = mailGunManager.sendEmail(FROM_ADDRESS, email, SUBJECT, body, html); + boolean mailSent = mailGunManager.sendEmailWithCC(FROM_ADDRESS, email, CC_ADDRESS, SUBJECT, body, html); if (!mailSent) { - LOG.error("Failed to send email for papi limits, orcid=" + profile.getId() + " email: " + email); + LOG.error("Failed to send email for papi limits, orcid=" + memberId + " email: " + email); } } @@ -281,7 +280,6 @@ private void setPapiRateExceededItemInPanoply(PanoplyPapiDailyRateExceededItem i if (!result) { LOG.error("Async call to panoply for : " + item.toString() + " Stored: " + result); } - }); } @@ -301,11 +299,10 @@ private String getClientIpAddress(HttpServletRequest request) { } private boolean isWhiteListed(String ipAddress) { - return (papiIpWhiteList != null)?papiIpWhiteList.contains(ipAddress): false; + return (papiIpWhiteList != null) ? papiIpWhiteList.contains(ipAddress) : false; } private boolean isClientIdWhiteListed(String clientId) { - return (papiClientIdWhiteList != null)?papiClientIdWhiteList.contains(clientId):false; + return (papiClientIdWhiteList != null) ? papiClientIdWhiteList.contains(clientId) :false; } - } diff --git a/orcid-pub-web/src/test/java/org/orcid/api/filters/ApiRateLimitFilterTest.java b/orcid-pub-web/src/test/java/org/orcid/api/filters/ApiRateLimitFilterTest.java new file mode 100644 index 00000000000..a43a0c9dd95 --- /dev/null +++ b/orcid-pub-web/src/test/java/org/orcid/api/filters/ApiRateLimitFilterTest.java @@ -0,0 +1,182 @@ +package org.orcid.api.filters; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.orcid.core.oauth.service.OrcidTokenStore; +import org.orcid.persistence.dao.PublicApiDailyRateLimitDao; +import org.orcid.persistence.jpa.entities.PublicApiDailyRateLimitEntity; +import org.orcid.test.OrcidJUnit4ClassRunner; +import org.orcid.test.TargetProxyHelper; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.ContextConfiguration; +import javax.annotation.Resource; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import java.io.IOException; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@RunWith(OrcidJUnit4ClassRunner.class) +@ContextConfiguration(locations = { "classpath:test-orcid-t1-web-context.xml" }) +public class ApiRateLimitFilterTest { + + @Resource + public ApiRateLimitFilter apiRateLimitFilter; + + @Mock + private FilterChain filterChainMock; + + @Mock + private OrcidTokenStore orcidTokenStoreMock; + + @Mock + private PublicApiDailyRateLimitDao papiRateLimitingDaoMock; + + MockHttpServletRequest httpServletRequestMock = new MockHttpServletRequest(); + + MockHttpServletResponse httpServletResponseMock = new MockHttpServletResponse(); + + @Test + public void doFilterInternal_rateLimitingDisabledTest() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", false); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(filterChainMock, times(1)).doFilter(eq(httpServletRequestMock), eq(httpServletResponseMock)); + verify(orcidTokenStoreMock, never()).readClientId(anyString()); + verify(papiRateLimitingDaoMock, never()).findByIpAddressAndRequestDate(anyString(), any()); + verify(papiRateLimitingDaoMock, never()).persist(any()); + } + + @Test + public void doFilterInternal_annonymousRequest_newEntry_X_FORWARDED_FOR_header_Test() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + String ip = "127.0.0.2"; + + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", true); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "papiRateLimitingDao", papiRateLimitingDaoMock); + + when(papiRateLimitingDaoMock.findByIpAddressAndRequestDate(eq(ip), any())).thenReturn(null); + httpServletRequestMock.addHeader("X-FORWARDED-FOR", ip); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(orcidTokenStoreMock, never()).readClientId(anyString()); + verify(papiRateLimitingDaoMock, never()).updatePublicApiDailyRateLimit(any(PublicApiDailyRateLimitEntity.class), anyBoolean()); + verify(papiRateLimitingDaoMock, times(1)).persist(any(PublicApiDailyRateLimitEntity.class)); + } + + @Test + public void doFilterInternal_annonymousRequest_newEntry_X_REAL_IP_header_Test() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + String ip = "127.0.0.2"; + + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", true); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "papiRateLimitingDao", papiRateLimitingDaoMock); + + when(papiRateLimitingDaoMock.findByIpAddressAndRequestDate(eq(ip), any())).thenReturn(null); + httpServletRequestMock.addHeader("X-REAL-IP", ip); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(orcidTokenStoreMock, never()).readClientId(anyString()); + verify(papiRateLimitingDaoMock, never()).updatePublicApiDailyRateLimit(any(PublicApiDailyRateLimitEntity.class), anyBoolean()); + verify(papiRateLimitingDaoMock, times(1)).persist(any(PublicApiDailyRateLimitEntity.class)); + } + + @Test + public void doFilterInternal_annonymousRequest_newEntry_whitelisted_IP_Test() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + String ip = "127.0.0.1"; + + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", true); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "papiRateLimitingDao", papiRateLimitingDaoMock); + + when(papiRateLimitingDaoMock.findByIpAddressAndRequestDate(eq(ip), any())).thenReturn(null); + httpServletRequestMock.addHeader("X-REAL-IP", ip); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(orcidTokenStoreMock, never()).readClientId(anyString()); + verify(papiRateLimitingDaoMock, never()).updatePublicApiDailyRateLimit(any(PublicApiDailyRateLimitEntity.class), anyBoolean()); + verify(papiRateLimitingDaoMock, never()).persist(any(PublicApiDailyRateLimitEntity.class)); + } + + @Test + public void doFilterInternal_annonymousRequest_existingEntryTest() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + String ip = "127.0.0.2"; + PublicApiDailyRateLimitEntity e = new PublicApiDailyRateLimitEntity(); + e.setId(1000L); + e.setIpAddress(ip); + e.setRequestCount(100L); + + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", true); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "papiRateLimitingDao", papiRateLimitingDaoMock); + + when(papiRateLimitingDaoMock.findByIpAddressAndRequestDate(eq(ip), any())).thenReturn(e); + httpServletRequestMock.addHeader("X-REAL-IP", ip); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(orcidTokenStoreMock, never()).readClientId(anyString()); + verify(papiRateLimitingDaoMock, times(1)).updatePublicApiDailyRateLimit(any(PublicApiDailyRateLimitEntity.class), eq(false)); + verify(papiRateLimitingDaoMock, never()).persist(any(PublicApiDailyRateLimitEntity.class)); + } + + @Test + public void doFilterInternal_clientRequest_newEntryTest() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + String ip = "127.0.0.2"; + String clientId = "clientId1"; + + httpServletRequestMock.addHeader("Authorization", "TEST_TOKEN"); + when(orcidTokenStoreMock.readClientId(eq("TEST_TOKEN"))).thenReturn(clientId); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", true); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "papiRateLimitingDao", papiRateLimitingDaoMock); + + when(papiRateLimitingDaoMock.findByClientIdAndRequestDate(eq(ip), any())).thenReturn(null); + httpServletRequestMock.addHeader("X-REAL-IP", ip); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(papiRateLimitingDaoMock, never()).updatePublicApiDailyRateLimit(any(PublicApiDailyRateLimitEntity.class), anyBoolean()); + verify(papiRateLimitingDaoMock, times(1)).persist(any(PublicApiDailyRateLimitEntity.class)); + } + + @Test + public void doFilterInternal_clientRequest_existingEntryTest() throws ServletException, IOException { + MockitoAnnotations.initMocks(this); + String ip = "127.0.0.2"; + String clientId = "clientId1"; + + PublicApiDailyRateLimitEntity e = new PublicApiDailyRateLimitEntity(); + e.setId(1000L); + e.setIpAddress(ip); + e.setRequestCount(100L); + + httpServletRequestMock.addHeader("Authorization", "TEST_TOKEN"); + when(orcidTokenStoreMock.readClientId(eq("TEST_TOKEN"))).thenReturn(clientId); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "enableRateLimiting", true); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "orcidTokenStore", orcidTokenStoreMock); + TargetProxyHelper.injectIntoProxy(apiRateLimitFilter, "papiRateLimitingDao", papiRateLimitingDaoMock); + + when(papiRateLimitingDaoMock.findByClientIdAndRequestDate(eq(clientId), any())).thenReturn(e); + httpServletRequestMock.addHeader("X-REAL-IP", ip); + + apiRateLimitFilter.doFilterInternal(httpServletRequestMock, httpServletResponseMock, filterChainMock); + + verify(papiRateLimitingDaoMock, times(1)).updatePublicApiDailyRateLimit(any(PublicApiDailyRateLimitEntity.class), eq(true)); + verify(papiRateLimitingDaoMock, never()).persist(any(PublicApiDailyRateLimitEntity.class)); + } +} \ No newline at end of file diff --git a/orcid-utils/src/main/java/org/orcid/utils/email/MailGunManager.java b/orcid-utils/src/main/java/org/orcid/utils/email/MailGunManager.java index 2daa316e5c1..d45428a6606 100644 --- a/orcid-utils/src/main/java/org/orcid/utils/email/MailGunManager.java +++ b/orcid-utils/src/main/java/org/orcid/utils/email/MailGunManager.java @@ -4,6 +4,7 @@ import javax.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; import org.orcid.utils.jersey.JerseyClientHelper; import org.orcid.utils.jersey.JerseyClientResponse; import org.slf4j.Logger; @@ -63,14 +64,18 @@ public void initJerseyClientHelper() { } public boolean sendMarketingEmail(String from, String to, String subject, String text, String html) { - return sendEmail(from, to, subject, text, html, true); + return sendEmail(from, to, null, subject, text, html, true); } public boolean sendEmail(String from, String to, String subject, String text, String html) { - return sendEmail(from, to, subject, text, html, false); + return sendEmail(from, to, null, subject, text, html, false); } - - public boolean sendEmail(String from, String to, String subject, String text, String html, boolean marketing) { + + public boolean sendEmailWithCC(String from, String to, String cc, String subject, String text, String html) { + return sendEmail(from, to, cc, subject, text, html, false); + } + + private boolean sendEmail(String from, String to, String cc, String subject, String text, String html, boolean marketing) { String fromEmail = getFromEmail(from); String apiUrl; if(marketing) @@ -84,6 +89,9 @@ else if (fromEmail.endsWith("@notify.orcid.org")) Form formData = new Form(); formData.param("from", from); formData.param("to", to); + if(StringUtils.isNotBlank(cc)) { + formData.param("cc", cc); + } formData.param("subject", subject); formData.param("text", text); if (html != null) {