diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernSecurityBlockingApiService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernSecurityBlockingApiService.java new file mode 100644 index 0000000000..643c4ecc36 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernSecurityBlockingApiService.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.api.registration.cern; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.web.client.RestClientException; + +import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO; + +@Profile("cern") +public interface CernSecurityBlockingApiService { + + /** + * Returns an @Optional object that contains the @VOPersonDTO related to the CERN personId + * provided as parameter or empty if not found. + * + * @param personId + * @return + * @throws RestClientException in case of ApiErrors + */ + Optional getSecurityBlockingRecord(String personId) throws RestClientException; + +} \ No newline at end of file diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernSecurityBlockingError.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernSecurityBlockingError.java new file mode 100644 index 0000000000..af214dc998 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernSecurityBlockingError.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.api.registration.cern; + +public class CernSecurityBlockingError extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public CernSecurityBlockingError(String message) { + super(message); + } + + public CernSecurityBlockingError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java index 8c2ca8cf72..e7161effb9 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java @@ -81,8 +81,8 @@ public Optional getHrDbPersonRecord(String personId) { new HttpEntity<>(buildAuthHeaders()), VOPersonDTO.class); return Optional.of(response.getBody()); } catch (RestClientException e) { - if ((e instanceof HttpClientErrorException) - && (((HttpClientErrorException) e).getStatusCode().equals(NOT_FOUND))) { + if (e instanceof HttpClientErrorException httpException + && httpException.getStatusCode().equals(NOT_FOUND)) { return Optional.empty(); } throw new CernHrDbApiError(e.getMessage(), e); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernSecurityBlockingService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernSecurityBlockingService.java new file mode 100644 index 0000000000..9f02edd7cf --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernSecurityBlockingService.java @@ -0,0 +1,144 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.api.registration.cern; + +import java.util.Optional; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO; +import it.infn.mw.iam.api.registration.cern.dto.CernTokenResponse; +import it.infn.mw.iam.authn.oidc.RestTemplateFactory; +import it.infn.mw.iam.config.cern.CernProperties; + +@Service +@Profile("cern") +public class DefaultCernSecurityBlockingService implements CernSecurityBlockingApiService { + + public static final Logger LOG = LoggerFactory.getLogger(DefaultCernSecurityBlockingService.class); + + public static final String QUERY_API_PATH = "/api/v1.0/Identity/-/Query"; + + private String cachedToken; + private Instant tokenExpiry; + final RestTemplateFactory rtFactory; + final CernProperties properties; + + public DefaultCernSecurityBlockingService(RestTemplateFactory rtFactory, CernProperties properties) { + this.rtFactory = rtFactory; + this.properties = properties; + } + + private HttpHeaders buildAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + getAccessToken()); + return headers; + } + + private String getAccessToken() { + + Instant now = Instant.now(); + + if (cachedToken != null && tokenExpiry != null && now.isBefore(tokenExpiry)) { + LOG.debug("Using cached access token, expires at {}", tokenExpiry); + return cachedToken; + } + LOG.debug("Requesting new access token from CERN Security Blocking API"); + RestTemplate rt = rtFactory.newRestTemplate(); + + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "client_credentials"); + form.add("client_id", properties.getBlocking().getClientId()); + form.add("client_secret", properties.getBlocking().getClientSecret()); + form.add("audience", properties.getBlocking().getAudience()); + + HttpEntity> request = + new HttpEntity<>(form, headers); + + LOG.debug("Requesting access token with client_id: {}, audience: {}, token_url: {}", properties.getBlocking().getClientId(), properties.getBlocking().getAudience(), properties.getBlocking().getTokenUrl()); + try { + ResponseEntity response = rt.exchange( + properties.getBlocking().getTokenUrl(), + HttpMethod.POST, + request, + CernTokenResponse.class + ); + CernTokenResponse body = response.getBody(); + if (body == null) { + LOG.warn("CERN security blocking token endpoint returned empty body"); + throw new CernSecurityBlockingError("CERN security blocking token endpoint returned empty body"); + } + + cachedToken = body.getAccessToken(); + LOG.debug("Received new access token, expires in {} seconds", body.getExpiresIn()); + + tokenExpiry = now.plusSeconds(body.getExpiresIn() - properties.getBlocking().getGracePeriod()); + } catch (RestClientException e) { + throw new CernSecurityBlockingError("Error fetching security blocking api access token: " + e.getMessage(), e); + } + + + return cachedToken; + } + + @Override + public Optional getSecurityBlockingRecord(String personId) { + RestTemplate restTemplate = rtFactory.newRestTemplate(); + + String url = String.format("%s%s", properties.getBlocking().getAuthorizationUrl(), + QUERY_API_PATH); + + LOG.debug("Checking security blocking for person {} with query at URL {}", personId, url); + + String data = String.format("{\"operator\":\"Equals\",\"value\":\"%s\",\"property\":\"personId\"}", + personId); + + HttpHeaders headers = buildAuthHeaders(); + headers.setContentType(org.springframework.http.MediaType.valueOf("application/json-patch+json")); + + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(data, headers), + VOPersonDTO.class + ); + return Optional.ofNullable(response.getBody()); + } catch (HttpClientErrorException.NotFound e) { + return Optional.empty(); + } catch (RestClientException e) { + throw new CernSecurityBlockingError("Error fetching security blocking record: " + e.getMessage(), e); + } + } + + +} \ No newline at end of file diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/CernTokenResponse.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/CernTokenResponse.java new file mode 100644 index 0000000000..be20eedce4 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/CernTokenResponse.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.api.registration.cern.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class CernTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private long expiresIn; + + @JsonProperty("token_type") + private String tokenType; + + public String getAccessToken() { + return accessToken; + } + + public long getExpiresIn() { + return expiresIn; + } + + public String getTokenType() { + return tokenType; + } +} \ No newline at end of file diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/VOPersonDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/VOPersonDTO.java index d31cd1ba63..535e81af1c 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/VOPersonDTO.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/dto/VOPersonDTO.java @@ -41,6 +41,7 @@ public class VOPersonDTO { private String email; private String physicalEmail; private Set participations; + private boolean blocked; public Long getId() { return id; @@ -177,4 +178,13 @@ public Set getParticipations() { public void setParticipations(Set participations) { this.participations = participations; } + + public boolean getBlocked() { + return blocked; + } + + public void setBlocked(boolean blocked) { + this.blocked = blocked; + } + } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java index f1cb59891e..a76d71c484 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java @@ -100,6 +100,109 @@ public void setPassword(String password) { } } + public static class CernBlockingProperties { + + boolean enabled = true; + + @NotBlank + String tokenUrl = "http://access.test.example"; + + @NotBlank + String authorizationUrl = "http://authorization.test.example"; + + + @NotBlank + String cronSchedule = "0 0 */12 * * *"; + + @Min(value = 5L) + int pageSize = 50; + + + @NotBlank + String audience = "authorization"; + + @NotBlank + String clientId = "client-id"; + + @NotBlank + String clientSecret = "client-secret"; + + @Min(value = 5L) + int gracePeriod = 30; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCronSchedule() { + return cronSchedule; + } + + public void setCronSchedule(String cronSchedule) { + this.cronSchedule = cronSchedule; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + + public String getAuthorizationUrl() { + return authorizationUrl; + } + + public void setAuthorizationUrl(String authorizationUrl) { + this.authorizationUrl = authorizationUrl; + } + + public String getAudience() { + return audience; + } + + public void setAudience(String audience) { + this.audience = audience; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public int getPageSize() { + return pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public int getGracePeriod() { + return gracePeriod; + } + + public void setGracePeriod(int gracePeriod) { + this.gracePeriod = gracePeriod; + } + } + @NotBlank private String ssoIssuer = "https://auth.cern.ch/auth/realms/cern"; @@ -115,6 +218,18 @@ public void setPassword(String password) { @Valid private HrSynchTaskProperties task = new HrSynchTaskProperties(); + @Valid + private CernBlockingProperties blocking = new CernBlockingProperties(); + + public CernBlockingProperties getBlocking() { + return blocking; + } + + public void setBlocking(CernBlockingProperties blocking) { + this.blocking = blocking; + } + + public HrDbApiProperties getHrApi() { return hrApi; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java index 6ec026578e..569d4e48c3 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java @@ -63,8 +63,8 @@ public class CernHrLifecycleHandler implements Runnable, SchedulingConfigurer { public static final String NO_PARTICIPATION_MESSAGE = "Account end-time not updated: no participation to %s found"; public static final String SYNCHRONIZED_MESSAGE = - "Account's membership to the experiment synchronized"; - + "Account's membership to the experiment synchronized"; + public static final String BLOCKED_MESSAGE = "Account is blocked at CERN"; public static final String HR_DB_API_ERROR = "Account not updated: HR DB error"; public static final int DEFAULT_PAGE_SIZE = 50; @@ -99,6 +99,12 @@ public void handleAccount(String cernPersonId, String experiment, IamAccount a) return; } + if (CernHrLifecycleUtils.isAccountBlocked(a)) { + LOG.info("Account is blocked: {}", a); + return; + } + + Optional voPerson = Optional.empty(); try { voPerson = hrDb.getHrDbPersonRecord(cernPersonId); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java index aded51bea1..6bcce0f244 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java @@ -41,8 +41,10 @@ public class CernHrLifecycleUtils { public static final String LABEL_MESSAGE = "message"; public static final String LABEL_ACTION = "action"; public static final String LABEL_IGNORE = "ignore"; + public static final String LABEL_BLOCKED = "blocked"; public static final String LABEL_SKIP_EMAIL_SYNCH = "skip-email-synch"; public static final String LABEL_SKIP_END_DATE_SYNCH = "skip-end-date-synch"; + public static final String BLOCKED_MESSAGE = "Account is blocked at CERN"; private CernHrLifecycleUtils() {} @@ -70,6 +72,10 @@ public static IamLabel buildCernIgnoreLabel() { return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_IGNORE).build(); } + public static IamLabel buildCernBlockedLabel() { + return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_STATUS).value(CernStatus.BLOCKED.name()).build(); + } + public static IamLabel buildCernMessageLabel(String message) { return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_MESSAGE).value(message).build(); @@ -106,4 +112,8 @@ public static boolean isActiveMembership(Date endTime) { public static boolean isAccountIgnored(IamAccount a) { return a.hasLabel(buildCernIgnoreLabel()); } + + public static boolean isAccountBlocked(IamAccount a) { + return a.hasLabel(buildCernBlockedLabel()); + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernSecurityBlockingHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernSecurityBlockingHandler.java new file mode 100644 index 0000000000..ad320fa3bf --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernSecurityBlockingHandler.java @@ -0,0 +1,164 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.core.lifecycle.cern; + +import static java.lang.String.format; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + + +import it.infn.mw.iam.api.registration.cern.CernSecurityBlockingApiService; +import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX; +import it.infn.mw.iam.api.registration.cern.CernSecurityBlockingError; +import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO; +import it.infn.mw.iam.config.cern.CernProperties; +import it.infn.mw.iam.core.user.IamAccountService; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.model.IamLabel; + +@Component +@Profile("cern") +public class CernSecurityBlockingHandler implements Runnable, SchedulingConfigurer { + + public static final Logger LOG = LoggerFactory.getLogger(CernSecurityBlockingHandler.class); + private final CernProperties cernProperties; + private final IamAccountRepository accountRepo; + private final IamAccountService accountService; + private final CernSecurityBlockingApiService cernSecurityBlockingApiService; + public static final String BLOCKED_MESSAGE = "Account is blocked at CERN"; + public static final String SYNCHRONIZED_MESSAGE ="Account's membership to the experiment synchronized"; + public static final String INVALID_ACCOUNT_MESSAGE = "Account has not the mandatory CERN person id label"; + public static final String LABEL_STATUS = "status"; + public static final String MISSING_STATUS_LABEL = "Account has not the mandatory CERN status label"; + + public CernSecurityBlockingHandler(CernProperties cernProperties, IamAccountRepository accountRepo, + IamAccountService accountService, CernSecurityBlockingApiService cernSecurityBlockingApiService) { + this.cernProperties = cernProperties; + this.accountRepo = accountRepo; + this.accountService = accountService; + this.cernSecurityBlockingApiService = cernSecurityBlockingApiService; + } + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + + if (!cernProperties.getBlocking().isEnabled()) { + LOG.info("CERN Security Blocking Handler is DISABLED"); + } else { + final String cronSchedule = cernProperties.getBlocking().getCronSchedule(); + LOG.info("Scheduling CERN Security Blocking Handler with schedule: {}", cronSchedule); + taskRegistrar.addCronTask(this, cronSchedule); + } + } + + public void handleAccount(IamAccount a) { + + String personId = getCernPersonId(a); + LOG.debug("Handling IAM account (username: {} , uuid: {})", personId, a.getUuid()); + + Optional voPerson = Optional.empty(); + try { + voPerson = cernSecurityBlockingApiService.getSecurityBlockingRecord(personId); + LOG.debug("Received security blocking information for account with personID: {} , blocking status: {}, active: {}", personId, voPerson.isPresent() ? voPerson.get().getBlocked() : "No record found", a.isActive()); + } catch (CernSecurityBlockingError e) { + LOG.error("Error contacting CERN Authorization api: {}", e.getMessage(), e); + return; + } + + if (!voPerson.isPresent()) { + LOG.warn("Account with personID: {} has no security blocking information in CERN", personId); + return; + } + + if (a.isActive() && voPerson.get().getBlocked()) { + LOG.info("Account with personID: {} is active but blocked in CERN, disabling account", personId); + disableAccount(a); + } + + if (!a.isActive() && !voPerson.get().getBlocked() && getBlockingLabel(a).equals(CernStatus.BLOCKED.name())){ + LOG.info("Account with personID: {} is disabled but not blocked in CERN, setting status label to ACTIVE", personId); + restoreAccount(a); + } + } + + @Override + public void run() { + + LOG.info("CERN Security Blocking Handler ... [START]"); + + Pageable pageRequest = PageRequest.of(0, cernProperties.getBlocking().getPageSize()); + + while (true) { + Page accountsPage = accountRepo.findByLabelPrefixAndName(LABEL_CERN_PREFIX,cernProperties.getPersonIdClaim(), pageRequest); + + if (accountsPage.hasContent()) { + for (IamAccount a : accountsPage.getContent()) { + try { + handleAccount(a); + } catch (RuntimeException e) { + LOG.error("Error during CERN Security Blocking Handler on account {}: {}", a, e.getMessage()); + } + } + } + + if (!accountsPage.hasNext()) { + break; + } + pageRequest = accountsPage.nextPageable(); + } + + LOG.info("CERN Security Blocking Handler ... [END]"); + } + + private void disableAccount(IamAccount a) { + accountService.disableAccount(a); + setCernStatusLabel(a, CernStatus.BLOCKED, BLOCKED_MESSAGE); + } + + private void setCernStatusLabel(IamAccount a, CernStatus status, String message) { + IamLabel statusLabel = CernHrLifecycleUtils.buildCernStatusLabel(status); + IamLabel messageLabel = CernHrLifecycleUtils.buildCernMessageLabel(message); + accountService.addLabel(a, statusLabel); + accountService.addLabel(a, messageLabel); + } + private void restoreAccount(IamAccount a) { + accountService.restoreAccount(a); + setCernStatusLabel(a, CernStatus.VO_MEMBER, format(SYNCHRONIZED_MESSAGE)); + } + private String getCernPersonId(IamAccount a) { + Optional cernPersonIdLabel = + a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, cernProperties.getPersonIdClaim()); + Assert.isTrue(cernPersonIdLabel.isPresent(), INVALID_ACCOUNT_MESSAGE); + return cernPersonIdLabel.get().getValue(); + } + private String getBlockingLabel(IamAccount a) { + Optional cernStatusLabel = a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + Assert.isTrue(cernStatusLabel.isPresent(), INVALID_ACCOUNT_MESSAGE); + return cernStatusLabel.get().getValue(); + } +} \ No newline at end of file diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java index 54695d042e..a3adfe5789 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java @@ -16,5 +16,5 @@ package it.infn.mw.iam.core.lifecycle.cern; public enum CernStatus { - IGNORED, ERROR, EXPIRED, VO_MEMBER + IGNORED, ERROR, EXPIRED, VO_MEMBER, BLOCKED } \ No newline at end of file diff --git a/iam-login-service/src/main/resources/application-cern.yml b/iam-login-service/src/main/resources/application-cern.yml index c8ceb67670..1914a8114d 100644 --- a/iam-login-service/src/main/resources/application-cern.yml +++ b/iam-login-service/src/main/resources/application-cern.yml @@ -31,4 +31,16 @@ cern: on-person-id-not-found: no_action # Action when the VO-person received from the HR API contains no participations to the experiment (even expired). # Values: no_action, disable_user - on-participation-not-found: no_action \ No newline at end of file + on-participation-not-found: no_action + + blocking: + enabled: true + cron-schedule: "0 */3 * * * *" + token_url: "https://auth.cern.ch/auth/realms/cern" + authorization_url: "https://auth.cern.ch/auth/realms/cern" + page-size: 50 + client-id: "client-id" + client-secret: "client-secret" + audience: "audience" + grace_period: 30 + \ No newline at end of file diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java index 528bf2e0b4..152da77b6d 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java @@ -629,6 +629,29 @@ void testIgnoreAccount() { assertThat(messageLabel.get().getValue(), is(IGNORE_MESSAGE)); } + @Test + void testBlockedAccount() { + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + + service.addLabel(testAccount, cernBlockedLabel()); + + cernHrLifecycleHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + + assertThat(testAccount.isActive(), is(true)); + Optional statusLabel = + testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + Optional timestampLabel = + testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP); + System.out.println(testAccount.getLabels()); + assertThat(statusLabel.isPresent(), is(true)); + assertThat(statusLabel.get().getValue(), is(CernStatus.BLOCKED.name())); + + assertThat(timestampLabel.isPresent(), is(false)); + } @Test void testPaginationWorks() { diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernSecurityBlockTest.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernSecurityBlockTest.java new file mode 100644 index 0000000000..438b3792ff --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernSecurityBlockTest.java @@ -0,0 +1,760 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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 it.infn.mw.iam.test.lifecycle.cern; + +import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX; +import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_STATUS; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; + +import java.lang.reflect.Method; + +import java.time.Clock; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.time.Instant; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.http.HttpHeaders; +import org.springframework.web.client.RestTemplate; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.client.response.MockRestResponseCreators; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mercateo.test.clock.TestClock; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.api.registration.cern.CernHrDBApiService; +import it.infn.mw.iam.api.registration.cern.CernSecurityBlockingError; +import it.infn.mw.iam.api.registration.cern.DefaultCernSecurityBlockingService; +import it.infn.mw.iam.api.registration.cern.CernSecurityBlockingApiService; +import it.infn.mw.iam.api.registration.cern.dto.CernTokenResponse; +import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO; +import it.infn.mw.iam.authn.oidc.RestTemplateFactory; +import it.infn.mw.iam.config.cern.CernProperties; +import it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler; +import it.infn.mw.iam.core.lifecycle.cern.CernSecurityBlockingHandler; +import it.infn.mw.iam.core.lifecycle.cern.CernStatus; +import it.infn.mw.iam.core.user.IamAccountService; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamLabel; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.test.api.TestSupport; +import it.infn.mw.iam.test.core.CoreControllerTestSupport; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; +import it.infn.mw.iam.test.util.oidc.MockRestTemplateFactory; + +@IamMockMvcIntegrationTest +@SpringBootTest(classes = {IamLoginService.class, CoreControllerTestSupport.class, + CernSecurityBlockTest.TestConfig.class}) +@TestPropertySource(properties = { + // @formatter:off + "cern.task.pageSize=5", + // @formatter:on +}) +@ActiveProfiles(value = {"h2-test", "cern"}) +class CernSecurityBlockTest extends TestSupport + implements LifecycleTestSupport { + + @TestConfiguration + public static class TestConfig { + @Bean + @Primary + Clock mockClock() { + return TestClock.fixed(NOW, ZoneId.systemDefault()); + } + + @Bean + @Primary + CernSecurityBlockingApiService blockingService() { + return mock(CernSecurityBlockingApiService.class); + } + + @Bean + @Primary + CernHrDBApiService hrDb() { + return mock(CernHrDBApiService.class); + } + + @Bean + @Primary + RestTemplateFactory mockRestTemplateFactory() { + return new MockRestTemplateFactory(); + } + } + @Autowired + IamAccountRepository repo; + + @Autowired + IamAccountService service; + + @Autowired + CernSecurityBlockingHandler cernSecurityBlockingHandler; + + @Autowired + CernSecurityBlockingApiService blockingService; + + @Autowired + CernHrDBApiService hrDb; + + @Autowired + CernHrLifecycleHandler cernHrLifecycleHandler; + + @Autowired + Clock clock; + + @Autowired + RestTemplateFactory rtf; + + MockRestTemplateFactory mockRtf; + RestTemplate restTemplate; + CernProperties props; + DefaultCernSecurityBlockingService svc; + IamAccount cernUser; + + @BeforeEach + void init() { + mockRtf = (MockRestTemplateFactory) rtf; + mockRtf.resetTemplate(); + restTemplate = mockRtf.newRestTemplate(); + + cernUser = IamAccount.newAccount(); + cernUser.setUsername(CERN_USER); + cernUser.setUuid(CERN_USER_UUID); + cernUser.setActive(true); + cernUser.setEndTime(Date.from(NOW.plus(165, ChronoUnit.DAYS))); + cernUser.getUserInfo().setEmail(CERN_USER + "@example"); + cernUser.getUserInfo().setGivenName("cern"); + cernUser.getUserInfo().setFamilyName("user"); + cernUser.getUserInfo().setEmailVerified(true); + service.createAccount(cernUser); + service.addLabel(cernUser, cernPersonIdLabel(CERN_PERSON_ID)); + + props = new CernProperties(); + CernProperties.CernBlockingProperties b = new CernProperties.CernBlockingProperties(); + b.setClientId("cid"); + b.setClientSecret("secret"); + b.setAudience("aud"); + b.setTokenUrl("https://token.url"); + b.setAuthorizationUrl("http://authorization.test.example"); + b.setGracePeriod(10); + props.setBlocking(b); + + svc = new DefaultCernSecurityBlockingService(rtf, props); + + } + + @AfterEach + void teardown() { + reset(blockingService); + reset(hrDb); + service.deleteAccount(cernUser); + } + + private IamAccount loadAccount(String username) { + return repo.findByUuid(username).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND)); + } + + private void primeToken(DefaultCernSecurityBlockingService service) throws Exception { + Field cachedTokenField = DefaultCernSecurityBlockingService.class + .getDeclaredField("cachedToken"); + cachedTokenField.setAccessible(true); + cachedTokenField.set(service, "test-token"); + + Field tokenExpiryField = DefaultCernSecurityBlockingService.class + .getDeclaredField("tokenExpiry"); + tokenExpiryField.setAccessible(true); + tokenExpiryField.set(service, java.time.Instant.now().plusSeconds(3600)); + } + + @Test + void testPaginationWorks() { + + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPerson(String.valueOf(new Random().nextLong() % 100L)))); + + Pageable pageRequest = PageRequest.of(0, 5, Direction.ASC, "username"); + Page accountPage = repo.findAll(pageRequest); + + for (IamAccount account : accountPage.getContent()) { + service.addLabel(account, cernPersonIdLabel(UUID.randomUUID().toString())); + } + + cernSecurityBlockingHandler.run(); + + accountPage = repo.findAll(pageRequest); + + for (IamAccount account : accountPage.getContent()) { + assertThat(account.isActive(), is(true)); + + } + assertThat(accountPage.getContent().size(), is(5)); + } + @Test + void testUserSuspensionWorks() { + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, true))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(false)); + Optional cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.BLOCKED.name())); + } + @Test + void testUserBlockedNoAction() { + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, true))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(false)); + Optional cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.BLOCKED.name())); + + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, true))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(false)); + + Optional cernStatusLabel2 = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel2.get().getValue(), is(CernStatus.BLOCKED.name())); + } + + @Test + void testUserRestorationWorks() { + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, true))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(false)); + Optional cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.BLOCKED.name())); + + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, false))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + + Optional cernStatusLabel2 = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel2.get().getValue(), is(CernStatus.VO_MEMBER.name())); + } + + @Test + void testUserSuspensionWithHRdb() { + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, true))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(false)); + Optional cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.BLOCKED.name())); + + VOPersonDTO voPerson = voPerson(CERN_PERSON_ID); + when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson)); + + cernHrLifecycleHandler.run(); + + assertThat(testAccount.isActive(), is(false)); + Optional cernStatusLabel2 = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel2.get().getValue(), is(CernStatus.BLOCKED.name())); + } + + @Test + void testUserRestorationWorksWithHRDb() { + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(true)); + + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, true))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + assertThat(testAccount.isActive(), is(false)); + Optional cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.BLOCKED.name())); + + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenReturn(Optional.of(voPersonSecurityDto(CERN_PERSON_ID, cernUser, false))); + + cernSecurityBlockingHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(testAccount.isActive(), is(true)); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name())); + + VOPersonDTO voPerson = voPerson(CERN_PERSON_ID); + when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson)); + + cernHrLifecycleHandler.run(); + + testAccount = loadAccount(CERN_USER_UUID); + cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS); + assertThat(testAccount.isActive(), is(true)); + assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name())); + assertThat(cernStatusLabel.get().getValue(), is(not(CernStatus.BLOCKED.name()))); + } + + @Test + void testApiErrorIsHandled() { + + when(blockingService.getSecurityBlockingRecord(anyString())) + .thenThrow(new CernSecurityBlockingError("API is unreachable")); + + cernSecurityBlockingHandler.run(); + + IamAccount testAccount = loadAccount(CERN_USER_UUID); + + assertThat(testAccount.isActive(), is(true)); + + } + + + @Test + void cernTokenResponseDefaults() throws Exception { + CernTokenResponse r = new CernTokenResponse(); + assertNull(r.getAccessToken()); + assertEquals(0L, r.getExpiresIn()); + assertNull(r.getTokenType()); + + String json = "{" + + "\"access_token\":\"abc123\"," + + "\"expires_in\":456," + + "\"token_type\":\"bearer\"" + + "}"; + ObjectMapper om = new ObjectMapper(); + CernTokenResponse r2 = om.readValue(json, CernTokenResponse.class); + assertEquals("abc123", r2.getAccessToken()); + assertEquals(456L, r2.getExpiresIn()); + assertEquals("bearer", r2.getTokenType()); + } + + @Test + void cernTokenResponseSerialization() throws Exception { + CernTokenResponse r = new CernTokenResponse(); + Field at = CernTokenResponse.class.getDeclaredField("accessToken"); + at.setAccessible(true); + at.set(r, "foo"); + Field ei = CernTokenResponse.class.getDeclaredField("expiresIn"); + ei.setAccessible(true); + ei.setLong(r, 789L); + Field tt = CernTokenResponse.class.getDeclaredField("tokenType"); + tt.setAccessible(true); + tt.set(r, "bearer test"); + + ObjectMapper om = new ObjectMapper(); + String out = om.writeValueAsString(r); + assertTrue(out.contains("\"access_token\":\"foo\"")); + assertTrue(out.contains("\"expires_in\":789")); + assertTrue(out.contains("\"token_type\":\"bearer test\"")); + } + + @Test + void testBuildAuthHeaders() throws Exception { + Field cachedTokenField = DefaultCernSecurityBlockingService.class + .getDeclaredField("cachedToken"); + cachedTokenField.setAccessible(true); + cachedTokenField.set(svc, "test-token-xyz"); + + Field tokenExpiryField = DefaultCernSecurityBlockingService.class + .getDeclaredField("tokenExpiry"); + tokenExpiryField.setAccessible(true); + tokenExpiryField.set(svc, java.time.Instant.now().plusSeconds(3600)); + + Method buildAuthHeaders = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("buildAuthHeaders"); + buildAuthHeaders.setAccessible(true); + HttpHeaders headers = (HttpHeaders) buildAuthHeaders.invoke(svc); + + assertEquals("Bearer test-token-xyz", headers.get("Authorization").get(0)); + } + + @Test + void testGetSecurityBlockingRecordSuccess() throws Exception { + primeToken(svc); + + VOPersonDTO voPerson = voPerson("12345"); + voPerson.setBlocked(false); + + String url = String.format("%s%s", props.getBlocking().getAuthorizationUrl(), + "/api/v1.0/Identity/-/Query"); + String personId = "testuser"; + String patch = String.format("{\"operator\":\"Equals\",\"value\":\"%s\",\"property\":\"personId\"}", + personId); + + MockRestServiceServer mockServer = mockRtf.getMockServer(); + ObjectMapper om = new ObjectMapper(); + mockServer.expect(requestTo(url)) + .andExpect(method(POST)) + .andExpect(content().contentType("application/json-patch+json")) + .andExpect(content().json(patch)) + .andRespond(MockRestResponseCreators.withSuccess(om.writeValueAsString(voPerson), + org.springframework.http.MediaType.APPLICATION_JSON)); + + Optional result = svc.getSecurityBlockingRecord(personId); + + mockServer.verify(); + assertTrue(result.isPresent()); + assertEquals(voPerson.getBlocked(), result.get().getBlocked()); + } + + @Test + void testGetSecurityBlockingRecordNotFound() throws Exception { + primeToken(svc); + + String url = String.format("%s%s", props.getBlocking().getAuthorizationUrl(), + "/api/v1.0/Identity/-/Query"); + String personId = "nonexistent"; + String patch = String.format("{\"operator\":\"Equals\",\"value\":\"%s\",\"property\":\"personId\"}", + personId); + + MockRestServiceServer mockServer = mockRtf.getMockServer(); + mockServer.expect(requestTo(url)) + .andExpect(method(POST)) + .andExpect(content().contentType("application/json-patch+json")) + .andExpect(content().json(patch)) + .andRespond(MockRestResponseCreators.withStatus(org.springframework.http.HttpStatus.NOT_FOUND)); + + Optional result = svc.getSecurityBlockingRecord(personId); + + mockServer.verify(); + assertFalse(result.isPresent()); + } + + @Test + void testGetSecurityBlockingRecordError() throws Exception { + primeToken(svc); + + String url = String.format("%s%s", props.getBlocking().getAuthorizationUrl(), + "/api/v1.0/Identity/-/Query"); + String personId = "testuser"; + String patch = String.format("{\"operator\":\"Equals\",\"value\":\"%s\",\"property\":\"personId\"}", + personId); + + MockRestServiceServer mockServer = mockRtf.getMockServer(); + mockServer.expect(requestTo(url)) + .andExpect(method(POST)) + .andExpect(content().contentType("application/json-patch+json")) + .andExpect(content().json(patch)) + .andRespond(MockRestResponseCreators.withStatus(INTERNAL_SERVER_ERROR)); + + CernSecurityBlockingError exception = assertThrows(CernSecurityBlockingError.class, () -> { + svc.getSecurityBlockingRecord(personId); + }); + + mockServer.verify(); + assertTrue(exception.getMessage().contains("Error fetching security blocking record")); + } + + @Test + void testGetAccessTokenSuccess() throws Exception { + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + ObjectMapper om = new ObjectMapper(); + + CernTokenResponse tokenResponse = new CernTokenResponse(); + Field at = CernTokenResponse.class.getDeclaredField("accessToken"); + at.setAccessible(true); + at.set(tokenResponse, "new-access-token-12345"); + Field ei = CernTokenResponse.class.getDeclaredField("expiresIn"); + ei.setAccessible(true); + ei.setLong(tokenResponse, 3600L); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withSuccess(om.writeValueAsString(tokenResponse), APPLICATION_JSON)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + String token = (String) getAccessToken.invoke(freshSvc); + + assertEquals("new-access-token-12345", token); + mockServer.verify(); + } + + @Test + void testGetAccessTokenMultipleCalls() throws Exception { + + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + ObjectMapper om = new ObjectMapper(); + + CernTokenResponse tokenResponse = new CernTokenResponse(); + Field at = CernTokenResponse.class.getDeclaredField("accessToken"); + at.setAccessible(true); + at.set(tokenResponse, "multi-call-token"); + Field ei = CernTokenResponse.class.getDeclaredField("expiresIn"); + ei.setAccessible(true); + ei.setLong(tokenResponse, 3600L); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withSuccess(om.writeValueAsString(tokenResponse),APPLICATION_JSON)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + + String token1 = (String) getAccessToken.invoke(freshSvc); + String token2 = (String) getAccessToken.invoke(freshSvc); + + assertEquals(token1, token2); + assertEquals("multi-call-token", token1); + + mockServer.verify(); + } + + @Test + void testGetAccessTokenCached() throws Exception { + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + Field cachedTokenField = DefaultCernSecurityBlockingService.class + .getDeclaredField("cachedToken"); + cachedTokenField.setAccessible(true); + cachedTokenField.set(freshSvc, "cached-token-xyz"); + + Field tokenExpiryField = DefaultCernSecurityBlockingService.class + .getDeclaredField("tokenExpiry"); + tokenExpiryField.setAccessible(true); + tokenExpiryField.set(freshSvc, java.time.Instant.now().plusSeconds(3600)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + String token = (String) getAccessToken.invoke(freshSvc); + + assertEquals("cached-token-xyz", token); + } + + @Test + void testGetAccessTokenExpiredRefetch() throws Exception { + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + Field cachedTokenField = DefaultCernSecurityBlockingService.class + .getDeclaredField("cachedToken"); + cachedTokenField.setAccessible(true); + cachedTokenField.set(freshSvc, "expired-token"); + + Field tokenExpiryField = DefaultCernSecurityBlockingService.class + .getDeclaredField("tokenExpiry"); + tokenExpiryField.setAccessible(true); + tokenExpiryField.set(freshSvc, Instant.now().minusSeconds(100)); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + ObjectMapper om = new ObjectMapper(); + + CernTokenResponse tokenResponse = new CernTokenResponse(); + Field at = CernTokenResponse.class.getDeclaredField("accessToken"); + at.setAccessible(true); + at.set(tokenResponse, "new-refreshed-token"); + Field ei = CernTokenResponse.class.getDeclaredField("expiresIn"); + ei.setAccessible(true); + ei.setLong(tokenResponse, 3600L); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withSuccess(om.writeValueAsString(tokenResponse), APPLICATION_JSON)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + String token = (String) getAccessToken.invoke(freshSvc); + + assertEquals("new-refreshed-token", token); + mockServer.verify(); + } + + @Test + void testGetAccessTokenEmptyResponseBody() throws Exception { + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withSuccess("",APPLICATION_JSON)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + + InvocationTargetException invocationException = + assertThrows(InvocationTargetException.class, () -> { + getAccessToken.invoke(freshSvc); + }); + + assertTrue(invocationException.getCause() instanceof CernSecurityBlockingError); + assertTrue(invocationException.getCause().getMessage().contains("empty body")); + mockServer.verify(); + } + + @Test + void testGetAccessTokenWithGracePeriod() throws Exception { + CernProperties testProps = new CernProperties(); + CernProperties.CernBlockingProperties b = new CernProperties.CernBlockingProperties(); + b.setClientId("cid"); + b.setClientSecret("secret"); + b.setAudience("aud"); + b.setTokenUrl("https://token.url"); + b.setAuthorizationUrl("http://authorization.test.example"); + b.setGracePeriod(60); + testProps.setBlocking(b); + + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, testProps); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + ObjectMapper om = new ObjectMapper(); + + CernTokenResponse tokenResponse = new CernTokenResponse(); + Field at = CernTokenResponse.class.getDeclaredField("accessToken"); + at.setAccessible(true); + at.set(tokenResponse, "token-with-grace"); + Field ei = CernTokenResponse.class.getDeclaredField("expiresIn"); + ei.setAccessible(true); + ei.setLong(tokenResponse, 3600L); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withSuccess(om.writeValueAsString(tokenResponse), + APPLICATION_JSON)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class + .getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + String token = (String) getAccessToken.invoke(freshSvc); + + assertEquals("token-with-grace", token); + + Field tokenExpiryField = DefaultCernSecurityBlockingService.class.getDeclaredField("tokenExpiry"); + tokenExpiryField.setAccessible(true); + + Instant expiry = (Instant) tokenExpiryField.get(freshSvc); + Instant endTimeWithoutGrace = Instant.now().plusSeconds(3600L); + Instant endTimeWithGrace = Instant.now().plusSeconds(3600L - 60L); + + + assertTrue(expiry.isBefore(endTimeWithoutGrace)); + assertTrue(expiry.isAfter(endTimeWithGrace.minusSeconds(2))); + + mockServer.verify(); + } + + @Test + void testGetAccessTokenError() throws Exception { + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withException( + new java.net.ConnectException("Connection refused"))); + + Method getAccessToken = DefaultCernSecurityBlockingService.class.getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + + InvocationTargetException invocationException = + assertThrows(InvocationTargetException.class, () -> { + getAccessToken.invoke(freshSvc); + }); + + assertTrue(invocationException.getCause() instanceof CernSecurityBlockingError); + assertTrue(invocationException.getCause().getMessage().contains("Error fetching security blocking api access token")); + mockServer.verify(); + } + + @Test + void testGetAccessTokenServerError() throws Exception { + DefaultCernSecurityBlockingService freshSvc = new DefaultCernSecurityBlockingService(rtf, props); + + MockRestServiceServer mockServer = mockRtf.resetTemplate(); + + mockServer.expect(MockRestRequestMatchers.anything()) + .andRespond(MockRestResponseCreators.withStatus(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR)); + + Method getAccessToken = DefaultCernSecurityBlockingService.class.getDeclaredMethod("getAccessToken"); + getAccessToken.setAccessible(true); + + InvocationTargetException invocationException = + assertThrows(InvocationTargetException.class, () -> { + getAccessToken.invoke(freshSvc); + }); + + assertTrue(invocationException.getCause() instanceof CernSecurityBlockingError); + assertTrue(invocationException.getCause().getMessage().contains("Error fetching security blocking api access token")); + mockServer.verify(); + } +} \ No newline at end of file diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java index 6b3bde2cc4..8199ce1ddc 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java @@ -20,6 +20,7 @@ import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_IGNORE; import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_SKIP_EMAIL_SYNCH; import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_SKIP_END_DATE_SYNCH; +import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_STATUS; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -28,13 +29,13 @@ import java.util.function.Supplier; import org.joda.time.LocalDate; - import com.google.common.collect.Sets; import it.infn.mw.iam.api.registration.cern.dto.InstituteDTO; import it.infn.mw.iam.api.registration.cern.dto.ParticipationDTO; import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO; import it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus; +import it.infn.mw.iam.core.lifecycle.cern.CernStatus; import it.infn.mw.iam.persistence.model.IamAccount; import it.infn.mw.iam.persistence.model.IamLabel; @@ -56,6 +57,9 @@ default IamLabel cernIgnoreLabel() { return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_IGNORE).build(); } + default IamLabel cernBlockedLabel() { + return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_STATUS).value(CernStatus.BLOCKED.name()).build(); + } default IamLabel skipEmailSyncLabel() { return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_SKIP_EMAIL_SYNCH).build(); @@ -177,4 +181,24 @@ default Supplier assertionError(String message) { return () -> new AssertionError(message); } + default VOPersonDTO voPersonSecurityDto(String personId, IamAccount account) { + VOPersonDTO dto = new VOPersonDTO(); + dto.setFirstName(account.getUserInfo().getGivenName()); + dto.setName(account.getUserInfo().getName()); + dto.setEmail(account.getUserInfo().getEmail()); + dto.setId(Long.parseLong(personId)); + return dto; + } + + default VOPersonDTO voPersonSecurityDto(String personId, IamAccount account, boolean blocked) { + VOPersonDTO dto = new VOPersonDTO(); + dto.setFirstName(account.getUserInfo().getGivenName()); + dto.setName(account.getUserInfo().getName()); + dto.setEmail(account.getUserInfo().getEmail()); + dto.setId(Long.parseLong(personId)); + dto.setBlocked(blocked); + return dto; + } + + }