diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index a8136225f..db2809c28 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -4,6 +4,7 @@ on: push: branches: - develop + pull_request: types: [opened, edited, reopened, synchronize] diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e340986..69da9c820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,7 +259,6 @@ fixes several bugs for the IAM login service. ## 1.7.2 (2021-12-03) This release provides a single dependency change for the IAM login service -application. ### Added @@ -271,6 +270,10 @@ This release provides changes and bug fixes to the IAM test client application. ### Added +This release provides changes and bug fixes to the IAM test client application. + +### Added + - The IAM test client application, in its default configuration, no longer exposes tokens, but only the claims contained in tokens. It's possible to revert to the previous behavior by setting the `IAM_CLIENT_HIDE_TOKENS=false` diff --git a/Jenkinsfile b/Jenkinsfile index 185e85069..2ad6da820 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -90,7 +90,7 @@ pipeline { post { always { script { - maybeArchiveJUnitReports() + maybeArchiveJUnitReportsWithJacoco() } } } diff --git a/iam-login-service/docker/Dockerfile.prod b/iam-login-service/docker/Dockerfile.prod index baa7a1293..91d592071 100644 --- a/iam-login-service/docker/Dockerfile.prod +++ b/iam-login-service/docker/Dockerfile.prod @@ -1,4 +1,5 @@ FROM eclipse-temurin:17 as builder + RUN mkdir /indigo-iam WORKDIR /indigo-iam COPY iam-login-service.war /indigo-iam/ diff --git a/iam-login-service/pom.xml b/iam-login-service/pom.xml index 31cd91555..39d51afa2 100644 --- a/iam-login-service/pom.xml +++ b/iam-login-service/pom.xml @@ -92,7 +92,6 @@ org.springframework.boot spring-boot-starter-data-redis - @@ -221,6 +220,11 @@ spring-security-oauth2 + + org.springframework.security + spring-security-oauth2-client + + org.springframework.security spring-security-test @@ -406,6 +410,13 @@ jaxb-runtime + + + dev.samstevens.totp + totp + 1.7.1 + + diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java index bd2c5a9e7..f00fd8840 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java @@ -15,8 +15,6 @@ */ package it.infn.mw.iam.api.account; -import static java.util.Objects.isNull; - import java.util.Optional; import org.springframework.security.authentication.AnonymousAuthenticationToken; @@ -26,6 +24,7 @@ import org.springframework.stereotype.Component; import it.infn.mw.iam.authn.util.Authorities; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; import it.infn.mw.iam.persistence.model.IamAccount; import it.infn.mw.iam.persistence.repository.IamAccountRepository; @@ -39,7 +38,7 @@ public AccountUtils(IamAccountRepository accountRepo) { } public boolean isRegisteredUser(Authentication auth) { - if (auth == null || auth.getAuthorities() == null) { + if (auth == null || auth.getAuthorities().isEmpty()) { return false; } @@ -47,13 +46,21 @@ public boolean isRegisteredUser(Authentication auth) { } public boolean isAdmin(Authentication auth) { - if (auth == null || auth.getAuthorities() == null) { + if (auth == null || auth.getAuthorities().isEmpty()) { return false; } return auth.getAuthorities().contains(Authorities.ROLE_ADMIN); } + public boolean isPreAuthenticated(Authentication auth) { + if (auth == null || auth.getAuthorities().isEmpty()) { + return false; + } + + return auth.getAuthorities().contains(Authorities.ROLE_PRE_AUTHENTICATED); + } + public boolean isAuthenticated() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); @@ -61,7 +68,8 @@ public boolean isAuthenticated() { } public boolean isAuthenticated(Authentication auth) { - return !(isNull(auth) || auth instanceof AnonymousAuthenticationToken); + return auth != null && !(auth instanceof AnonymousAuthenticationToken) + && (!(auth instanceof ExtendedAuthenticationToken) || auth.isAuthenticated()); } public Optional getAuthenticatedUserAccount(Authentication authn) { @@ -70,7 +78,7 @@ public Optional getAuthenticatedUserAccount(Authentication authn) { } Authentication userAuthn = authn; - + if (authn instanceof OAuth2Authentication) { OAuth2Authentication oauth = (OAuth2Authentication) authn; if (oauth.getUserAuthentication() == null) { @@ -84,13 +92,13 @@ public Optional getAuthenticatedUserAccount(Authentication authn) { } public Optional getAuthenticatedUserAccount() { - + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - + return getAuthenticatedUserAccount(auth); } - - public Optional getByAccountId(String accountId){ + + public Optional getByAccountId(String accountId) { return accountRepo.findByUuid(accountId); } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java new file mode 100644 index 000000000..ed3543025 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java @@ -0,0 +1,191 @@ +/** + * 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.account.multi_factor_authentication; + +import java.util.Optional; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.stereotype.Service; + +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.secret.SecretGenerator; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppDisabledEvent; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppEnabledEvent; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.TotpVerifiedEvent; +import it.infn.mw.iam.config.mfa.IamTotpMfaProperties; +import it.infn.mw.iam.core.user.IamAccountService; +import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; +import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError; + +@Service +public class DefaultIamTotpMfaService implements IamTotpMfaService, ApplicationEventPublisherAware { + + public static final int RECOVERY_CODE_QUANTITY = 6; + private static final String MFA_SECRET_NOT_FOUND_MESSAGE = "No multi-factor secret is attached to this account"; + + private final IamAccountService iamAccountService; + private final IamTotpMfaRepository totpMfaRepository; + private final SecretGenerator secretGenerator; + private final CodeVerifier codeVerifier; + private final IamTotpMfaProperties iamTotpMfaProperties; + private ApplicationEventPublisher eventPublisher; + + public DefaultIamTotpMfaService(IamAccountService iamAccountService, + IamTotpMfaRepository totpMfaRepository, SecretGenerator secretGenerator, + CodeVerifier codeVerifier, ApplicationEventPublisher eventPublisher, + IamTotpMfaProperties iamTotpMfaProperties) { + this.iamAccountService = iamAccountService; + this.totpMfaRepository = totpMfaRepository; + this.secretGenerator = secretGenerator; + this.codeVerifier = codeVerifier; + this.eventPublisher = eventPublisher; + this.iamTotpMfaProperties = iamTotpMfaProperties; + } + + private void authenticatorAppEnabledEvent(IamAccount account, IamTotpMfa totpMfa) { + eventPublisher.publishEvent(new AuthenticatorAppEnabledEvent(this, account, totpMfa)); + } + + private void authenticatorAppDisabledEvent(IamAccount account, IamTotpMfa totpMfa) { + eventPublisher.publishEvent(new AuthenticatorAppDisabledEvent(this, account, totpMfa)); + } + + private void totpVerifiedEvent(IamAccount account, IamTotpMfa totpMfa) { + eventPublisher.publishEvent(new TotpVerifiedEvent(this, account, totpMfa)); + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.eventPublisher = applicationEventPublisher; + } + + /** + * Generates and attaches a TOTP MFA secret to a user account + * This is pre-emptive to actually enabling TOTP MFA on the account - the secret is written for + * server-side TOTP verification during the user's enabling of MFA on their account + * + * @param account the account to add the secret to + * @return the new TOTP secret + */ + @Override + public IamTotpMfa addTotpMfaSecret(IamAccount account) throws IamTotpMfaInvalidArgumentError { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (totpMfaOptional.isPresent()) { + if (totpMfaOptional.get().isActive()) { + throw new MfaSecretAlreadyBoundException( + "A multi-factor secret is already assigned to this account"); + } + + totpMfaRepository.delete(totpMfaOptional.get()); + } + + // Generate secret + IamTotpMfa totpMfa = new IamTotpMfa(account); + + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret( + secretGenerator.generate(), iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + totpMfa.setAccount(account); + + totpMfaRepository.save(totpMfa); + + return totpMfa; + } + + /** + * Enables TOTP MFA on a provided account. Relies on the account already having a non-active TOTP + * secret attached to it + * + * @param account the account to enable TOTP MFA on + * @return the newly-enabled TOTP secret + */ + @Override + public IamTotpMfa enableTotpMfa(IamAccount account) { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + if (totpMfa.isActive()) { + throw new TotpMfaAlreadyEnabledException("TOTP MFA is already enabled on this account"); + } + + totpMfa.setActive(true); + totpMfa.touch(); + totpMfaRepository.save(totpMfa); + iamAccountService.saveAccount(account); + authenticatorAppEnabledEvent(account, totpMfa); + return totpMfa; + } + + /** + * Disables TOTP MFA on a provided account. Relies on the account having an active TOTP secret + * attached to it. Disabling means to delete the secret entirely (if a user chooses to enable + * again, a new secret is generated anyway) + * + * @param account the account to disable TOTP MFA on + * @return the newly-disabled TOTP MFA + */ + @Override + public IamTotpMfa disableTotpMfa(IamAccount account) { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + totpMfaRepository.delete(totpMfa); + + iamAccountService.saveAccount(account); + authenticatorAppDisabledEvent(account, totpMfa); + return totpMfa; + } + + /** + * Verifies a provided TOTP against an account multi-factor secret + * + * @param account the account whose secret we will check against + * @param totp the TOTP to validate + * @return true if valid, false otherwise + */ + @Override + public boolean verifyTotp(IamAccount account, String totp) throws IamTotpMfaInvalidArgumentError { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + String mfaSecret = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret( + totpMfa.getSecret(), iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()); + + // Verify provided TOTP + if (codeVerifier.isValidCode(mfaSecret, totp)) { + totpVerifiedEvent(account, totpMfa); + return true; + } + + return false; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java new file mode 100644 index 000000000..db141d352 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java @@ -0,0 +1,61 @@ +/** + * 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.account.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; + +public interface IamTotpMfaService { + + /** + * Generates and attaches a TOTP MFA secret to a user account + * This is pre-emptive to actually enabling TOTP MFA on the account - the secret is written for + * server-side TOTP verification during the user's enabling of MFA on their account + * + * @param account the account to add the secret to + * @return the new TOTP secret + */ + IamTotpMfa addTotpMfaSecret(IamAccount account); + + /** + * Enables TOTP MFA on a provided account. Relies on the account already having a non-active TOTP + * secret attached to it + * + * @param account the account to enable TOTP MFA on + * @return the newly-enabled TOTP secret + */ + IamTotpMfa enableTotpMfa(IamAccount account); + + /** + * Disables TOTP MFA on a provided account. Relies on the account having an active TOTP secret + * attached to it. Disabling means to delete the secret entirely (if a user chooses to enable + * again, a new secret is generated anyway) + * + * @param account the account to disable TOTP MFA on + * @return the newly-disabled TOTP MFA + */ + IamTotpMfa disableTotpMfa(IamAccount account); + + /** + * Verifies a provided TOTP against an account multi-factor secret + * + * @param account the account whose secret we will check against + * @param totp the TOTP to validate + * @return true if valid, false otherwise + */ + boolean verifyTotp(IamAccount account, String totp); + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java new file mode 100644 index 000000000..8db9c79a6 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java @@ -0,0 +1,118 @@ +/** + * 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.account.multi_factor_authentication; + +import java.util.Optional; + +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ResponseBody; + +import it.infn.mw.iam.api.common.NoSuchAccountError; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; + +/** + * Controller for retrieving all multi-factor settings for a user account + */ +@SuppressWarnings("deprecation") +@Controller +public class MultiFactorSettingsController { + + public static final String MULTI_FACTOR_SETTINGS_URL = "/iam/multi-factor-settings"; + public static final String MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL = "/iam/multi-factor-settings/{accountId}"; + private final IamAccountRepository accountRepository; + private final IamTotpMfaRepository totpMfaRepository; + + public MultiFactorSettingsController(IamAccountRepository accountRepository, + IamTotpMfaRepository totpMfaRepository) { + this.accountRepository = accountRepository; + this.totpMfaRepository = totpMfaRepository; + } + + /** + * Retrieve info about MFA settings and return them in a DTO + * + * @return MultiFactorSettingsDTO the MFA settings for the account + */ + @PreAuthorize("hasRole('ADMIN')") + @GetMapping(value = MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public MultiFactorSettingsDTO getMultiFactorSettingsForAccount(@PathVariable String accountId) { + IamAccount account = accountRepository.findByUuid(accountId).orElseThrow(() -> NoSuchAccountError.forUuid(accountId)); + + boolean isActive = totpMfaRepository.findByAccount(account) + .map(IamTotpMfa::isActive) + .orElse(false); + + MultiFactorSettingsDTO dto = new MultiFactorSettingsDTO(); + dto.setAuthenticatorAppActive(isActive); + return dto; + } + + + /** + * Retrieve info about MFA settings and return them in a DTO + * + * @return MultiFactorSettingsDTO the MFA settings for the account + */ + @PreAuthorize("hasRole('USER')") + @GetMapping(value = MULTI_FACTOR_SETTINGS_URL, + produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public MultiFactorSettingsDTO getMultiFactorSettings() { + + final String username = getUsernameFromSecurityContext(); + IamAccount account = accountRepository.findByUsername(username) + .orElseThrow(() -> NoSuchAccountError.forUsername(username)); + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + MultiFactorSettingsDTO dto = new MultiFactorSettingsDTO(); + if (totpMfaOptional.isPresent()) { + IamTotpMfa totpMfa = totpMfaOptional.get(); + dto.setAuthenticatorAppActive(totpMfa.isActive()); + } else { + dto.setAuthenticatorAppActive(false); + } + + // add further factors if/when implemented + + return dto; + } + + + /** + * Fetch and return the logged-in username from security context + * + * @return String username + */ + private String getUsernameFromSecurityContext() { + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof OAuth2Authentication) { + OAuth2Authentication oauth = (OAuth2Authentication) auth; + auth = oauth.getUserAuthentication(); + } + return auth.getName(); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsDTO.java new file mode 100644 index 000000000..06fbd9492 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsDTO.java @@ -0,0 +1,59 @@ +/** + * 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.account.multi_factor_authentication; + +import javax.validation.constraints.NotEmpty; + +import com.nimbusds.jose.shaded.json.JSONObject; + +/** + * DTO containing info about enabled factors of authentication + */ +public class MultiFactorSettingsDTO { + + @NotEmpty + private boolean authenticatorAppActive; + + // add further factors if/when implemented + + public MultiFactorSettingsDTO() {} + + public MultiFactorSettingsDTO(final boolean authenticatorAppActive) { + this.authenticatorAppActive = authenticatorAppActive; + } + + + /** + * @return true if authenticator app is active + */ + public boolean getAuthenticatorAppActive() { + return authenticatorAppActive; + } + + + /** + * @param authenticatorAppActive new status of authenticator app + */ + public void setAuthenticatorAppActive(final boolean authenticatorAppActive) { + this.authenticatorAppActive = authenticatorAppActive; + } + + public JSONObject toJson() { + JSONObject json = new JSONObject(); + json.put("authenticatorAppActive", authenticatorAppActive); + return json; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsController.java new file mode 100644 index 000000000..bf449aff5 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsController.java @@ -0,0 +1,301 @@ +/** + * 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.account.multi_factor_authentication.authenticator_app; + +import static dev.samstevens.totp.util.Utils.getDataUriForImage; + +import javax.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import dev.samstevens.totp.code.HashingAlgorithm; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrGenerator; +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.error.BadMfaCodeError; +import it.infn.mw.iam.api.common.ErrorDTO; +import it.infn.mw.iam.api.common.NoSuchAccountError; +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.config.mfa.IamTotpMfaProperties; +import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException; +import it.infn.mw.iam.notification.NotificationFactory; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; +import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError; + +/** + * Controller for customising user's authenticator app MFA settings Can enable or disable the + * feature through POST requests to the relevant endpoints + */ +@SuppressWarnings("deprecation") +@Controller +public class AuthenticatorAppSettingsController { + + public static final String BASE_URL = "/iam/authenticator-app"; + public static final String ADD_SECRET_URL = BASE_URL + "/add-secret"; + public static final String ENABLE_URL = BASE_URL + "/enable"; + public static final String DISABLE_URL = BASE_URL + "/disable"; + public static final String DISABLE_URL_FOR_ACCOUNT_ID = BASE_URL + "/reset/{accountId}"; + public static final String BAD_CODE = "Bad TOTP"; + public static final String CODE_GENERATION_ERROR = "Could not generate QR code"; + public static final String MFA_SECRET_NOT_FOUND_MESSAGE = + "No multi-factor secret is attached to this account"; + + private final IamTotpMfaService service; + private final IamAccountRepository accountRepository; + private final QrGenerator qrGenerator; + private final IamTotpMfaProperties iamTotpMfaProperties; + private final IamProperties iamProperties; + private final NotificationFactory notificationFactory; + + public AuthenticatorAppSettingsController(IamTotpMfaService service, + IamAccountRepository accountRepository, QrGenerator qrGenerator, + IamTotpMfaProperties iamTotpMfaProperties, IamProperties iamProperties, + NotificationFactory notificationFactory) { + this.service = service; + this.accountRepository = accountRepository; + this.qrGenerator = qrGenerator; + this.iamTotpMfaProperties = iamTotpMfaProperties; + this.iamProperties = iamProperties; + this.notificationFactory = notificationFactory; + } + + /** + * Before we can enable authenticator app, we must first add a TOTP secret to the user's account + * The secret is not active until the user enables authenticator app at the /enable endpoint + * + * @return DTO containing the plaintext TOTP secret and QR code URI for scanning + */ + @PreAuthorize("hasRole('USER')") + @PutMapping(value = ADD_SECRET_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public SecretAndDataUriDTO addSecret() throws IamTotpMfaInvalidArgumentError { + final String username = getUsernameFromSecurityContext(); + IamAccount account = accountRepository.findByUsername(username) + .orElseThrow(() -> NoSuchAccountError.forUsername(username)); + + IamTotpMfa totpMfa = service.addTotpMfaSecret(account); + String mfaSecret = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(totpMfa.getSecret(), + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()); + + try { + SecretAndDataUriDTO dto = new SecretAndDataUriDTO(mfaSecret); + + String dataUri = generateQRCodeFromSecret(mfaSecret, account.getUsername()); + dto.setDataUri(dataUri); + + return dto; + } catch (QrGenerationException e) { + throw new BadMfaCodeError(CODE_GENERATION_ERROR); + } + } + + /** + * Enable authenticator app MFA on account User sends a TOTP through POST which we verify before + * enabling + * + * @param code the TOTP to verify + * @param validationResult result of validation checks on the code + * @return nothing + */ + @PreAuthorize("hasRole('USER')") + @PostMapping(value = ENABLE_URL, produces = MediaType.TEXT_PLAIN_VALUE) + @ResponseBody + public void enableAuthenticatorApp(@ModelAttribute @Valid CodeDTO code, + BindingResult validationResult) { + if (validationResult.hasErrors()) { + throw new BadMfaCodeError(BAD_CODE); + } + + final String username = getUsernameFromSecurityContext(); + IamAccount account = accountRepository.findByUsername(username) + .orElseThrow(() -> NoSuchAccountError.forUsername(username)); + + boolean valid = false; + + try { + valid = service.verifyTotp(account, code.getCode()); + } catch (MfaSecretNotFoundException e) { + throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE); + } + + if (!valid) { + throw new BadMfaCodeError(BAD_CODE); + } + + service.enableTotpMfa(account); + notificationFactory.createMfaEnableMessage(account); + } + + + /** + * Disable authenticator app MFA on account User sends a TOTP through POST which we verify before + * disabling + * + * @param code the TOTP to verify + * @param validationResult result of validation checks on the code + * @return nothing + */ + @PreAuthorize("hasRole('USER')") + @PostMapping(value = DISABLE_URL, produces = MediaType.TEXT_PLAIN_VALUE) + @ResponseBody + public void disableAuthenticatorApp(@Valid CodeDTO code, BindingResult validationResult) { + if (validationResult.hasErrors()) { + throw new BadMfaCodeError(BAD_CODE); + } + + final String username = getUsernameFromSecurityContext(); + IamAccount account = accountRepository.findByUsername(username) + .orElseThrow(() -> NoSuchAccountError.forUsername(username)); + + boolean valid = false; + + try { + valid = service.verifyTotp(account, code.getCode()); + } catch (MfaSecretNotFoundException e) { + throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE); + } + + if (!valid) { + throw new BadMfaCodeError(BAD_CODE); + } + + service.disableTotpMfa(account); + notificationFactory.createMfaDisableMessage(account); + } + + /** + * Reset authenticator app MFA on account by Admin on request + * + * @param accountId the accountId to get user account + * @return nothing + */ + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping(value = DISABLE_URL_FOR_ACCOUNT_ID, produces = MediaType.TEXT_PLAIN_VALUE) + @ResponseBody + public void disableAuthenticatorAppForAccount(@PathVariable String accountId) { + IamAccount account = accountRepository.findByUuid(accountId) + .orElseThrow(() -> NoSuchAccountError.forUuid(accountId)); + service.disableTotpMfa(account); + notificationFactory.createMfaDisableMessage(account); + } + + /** + * Fetch and return the logged-in username from security context + * + * @return String username + */ + private String getUsernameFromSecurityContext() { + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof OAuth2Authentication) { + OAuth2Authentication oauth = (OAuth2Authentication) auth; + auth = oauth.getUserAuthentication(); + } + return auth.getName(); + } + + /** + * Constructs a data URI for displaying a QR code of the TOTP secret for the user to scan Takes in + * details about the issuer, length of TOTP and period of expiry from application properties + * + * @param secret the TOTP secret + * @param username the logged-in user (attaches a username to the secret in the authenticator app) + * @return the data URI to be used with an tag + * @throws QrGenerationException + */ + private String generateQRCodeFromSecret(String secret, String username) + throws QrGenerationException { + + QrData data = new QrData.Builder().label(username) + .secret(secret) + .issuer("INDIGO IAM" + " - " + iamProperties.getOrganisation().getName()) + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build(); + + byte[] imageData = qrGenerator.generate(data); + String mimeType = qrGenerator.getImageMimeType(); + return getDataUriForImage(imageData, mimeType); + } + + + /** + * Exception handler for when an TOTP secret is unexpectedly missing + * + * @param e MfaSecretNotFoundException + * @return DTO containing error details + */ + @ResponseStatus(code = HttpStatus.CONFLICT) + @ExceptionHandler(MfaSecretNotFoundException.class) + @ResponseBody + public ErrorDTO handleMfaSecretNotFoundException(MfaSecretNotFoundException e) { + return ErrorDTO.fromString(e.getMessage()); + } + + /** + * Exception handler for when an TOTP secret is unexpectedly found + * + * @param e MfaSecretAlreadyBoundException + * @return DTO containing error details + */ + @ResponseStatus(code = HttpStatus.CONFLICT) + @ExceptionHandler(MfaSecretAlreadyBoundException.class) + @ResponseBody + public ErrorDTO handleMfaSecretAlreadyBoundException(MfaSecretAlreadyBoundException e) { + return ErrorDTO.fromString(e.getMessage()); + } + + /** + * Exception handler for when authenticator app MFA is unexpectedly enabled already + * + * @param e TotpMfaAlreadyEnabledException + * @return DTO containing error details + */ + @ResponseStatus(code = HttpStatus.CONFLICT) + @ExceptionHandler(TotpMfaAlreadyEnabledException.class) + @ResponseBody + public ErrorDTO handleTotpMfaAlreadyEnabledException(TotpMfaAlreadyEnabledException e) { + return ErrorDTO.fromString(e.getMessage()); + } + + + /** + * Exception handler for when a received TOTP is invalid + * + * @param e BadCodeError + * @return DTO containing error details + */ + @ResponseStatus(code = HttpStatus.BAD_REQUEST) + @ExceptionHandler(BadMfaCodeError.class) + @ResponseBody + public ErrorDTO handleBadCodeError(BadMfaCodeError e) { + return ErrorDTO.fromString(e.getMessage()); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/CodeDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/CodeDTO.java new file mode 100644 index 000000000..5264458f9 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/CodeDTO.java @@ -0,0 +1,48 @@ +/** + * 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.account.multi_factor_authentication.authenticator_app; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotEmpty; + +import org.hibernate.validator.constraints.Length; + +/** + * DTO containing a TOTP for MFA secrets + */ +public class CodeDTO { + + @NotEmpty(message = "Code cannot be empty") + @Length(min = 6, max = 6, message = "Code must be six characters in length") + @Min(value = 0L, message = "Code must be a numerical value") + private String code; + + + /** + * @return the code + */ + public String getCode() { + return code; + } + + + /** + * @param code new code + */ + public void setCode(final String code) { + this.code = code; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/SecretAndDataUriDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/SecretAndDataUriDTO.java new file mode 100644 index 000000000..649293dc4 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/SecretAndDataUriDTO.java @@ -0,0 +1,65 @@ +/** + * 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.account.multi_factor_authentication.authenticator_app; + +import javax.validation.constraints.NotEmpty; + +/** + * DTO containing an MFA secret and QR code data URI + */ +public class SecretAndDataUriDTO { + + @NotEmpty(message = "Secret cannot be empty") + private String secret; + + private String dataUri; + + public SecretAndDataUriDTO(final String secret) { + this.secret = secret; + } + + + /** + * @return the MFA secret + */ + public String getSecret() { + return secret; + } + + + /** + * @param secret the new secret + */ + public void setSecret(final String secret) { + this.secret = secret; + } + + + /** + * @return the QR code data URI + */ + public String getDataUri() { + return dataUri; + } + + + /** + * @param dataUri the new QR code data URI + */ + public void setDataUri(final String dataUri) { + this.dataUri = dataUri; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/error/BadMfaCodeError.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/error/BadMfaCodeError.java new file mode 100644 index 000000000..88ab60b00 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/error/BadMfaCodeError.java @@ -0,0 +1,25 @@ +/** + * 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.account.multi_factor_authentication.authenticator_app.error; + +public class BadMfaCodeError extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public BadMfaCodeError(String msg) { + super(msg); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java index cdb64ebed..dfad85f5f 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java @@ -71,4 +71,4 @@ public ScimAttribute build() { return new ScimAttribute(this); } } -} +} \ No newline at end of file diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppDisabledEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppDisabledEvent.java new file mode 100644 index 000000000..60bbb5a47 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppDisabledEvent.java @@ -0,0 +1,30 @@ +/** + * 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.audit.events.account.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; + +public class AuthenticatorAppDisabledEvent extends MultiFactorEvent { + + public static final String TEMPLATE = "Authenticator app MFA disabled on account '%s'"; + + private static final long serialVersionUID = 1L; + + public AuthenticatorAppDisabledEvent(Object source, IamAccount account, IamTotpMfa totpMfa) { + super(source, account, totpMfa, String.format(TEMPLATE, account.getUsername())); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppEnabledEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppEnabledEvent.java new file mode 100644 index 000000000..b2ecf1e4d --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppEnabledEvent.java @@ -0,0 +1,30 @@ +/** + * 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.audit.events.account.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; + +public class AuthenticatorAppEnabledEvent extends MultiFactorEvent { + + public static final String TEMPLATE = "Authenticator app MFA enabled on account '%s'"; + + private static final long serialVersionUID = 1L; + + public AuthenticatorAppEnabledEvent(Object source, IamAccount account, IamTotpMfa totpMfa) { + super(source, account, totpMfa, String.format(TEMPLATE, account.getUsername())); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/MultiFactorEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/MultiFactorEvent.java new file mode 100644 index 000000000..61008b65f --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/MultiFactorEvent.java @@ -0,0 +1,36 @@ +/** + * 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.audit.events.account.multi_factor_authentication; + +import it.infn.mw.iam.audit.events.account.AccountEvent; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; + +public class MultiFactorEvent extends AccountEvent { + + private static final long serialVersionUID = 1L; + private final IamTotpMfa totpMfa; + + protected MultiFactorEvent(Object source, IamAccount account, IamTotpMfa totpMfa, + String message) { + super(source, account, message); + this.totpMfa = totpMfa; + } + + public IamTotpMfa getTotpMfa() { + return totpMfa; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/TotpVerifiedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/TotpVerifiedEvent.java new file mode 100644 index 000000000..8ff5e8c32 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/TotpVerifiedEvent.java @@ -0,0 +1,30 @@ +/** + * 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.audit.events.account.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; + +public class TotpVerifiedEvent extends MultiFactorEvent { + + public static final String TEMPLATE = "MFA TOTP verified for account '%s'"; + + private static final long serialVersionUID = 1L; + + public TotpVerifiedEvent(Object source, IamAccount account, IamTotpMfa totpMfa) { + super(source, account, totpMfa, String.format(TEMPLATE, account.getUsername())); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java new file mode 100644 index 000000000..eebdede7d --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java @@ -0,0 +1,130 @@ +/** + * 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.authn; + +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; + +import java.io.IOException; +import java.util.Collection; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.WebAttributes; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; + +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.authn.util.Authorities; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; + +/** + * Success handler for the normal login flow. This determines if MFA is enabled on an account and, + * if so, redirects the user to a verification page. Otherwise, the default success handler is + * called + */ +public class CheckMultiFactorIsEnabledSuccessHandler implements AuthenticationSuccessHandler { + + private static final Logger logger = LoggerFactory.getLogger(CheckMultiFactorIsEnabledSuccessHandler.class); + + private final AccountUtils accountUtils; + private final String iamBaseUrl; + private final AUPSignatureCheckService aupSignatureCheckService; + private final IamAccountRepository accountRepo; + + public CheckMultiFactorIsEnabledSuccessHandler(AccountUtils accountUtils, String iamBaseUrl, + AUPSignatureCheckService aupSignatureCheckService, IamAccountRepository accountRepo) { + this.accountUtils = accountUtils; + this.iamBaseUrl = iamBaseUrl; + this.aupSignatureCheckService = aupSignatureCheckService; + this.accountRepo = accountRepo; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + handle(request, response, authentication); + clearAuthenticationAttributes(request); + } + + protected void handle(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + boolean isPreAuthenticated = isPreAuthenticated(authentication); + + if (response.isCommitted()) { + logger.warn("Response has already been committed. Unable to redirect to " + MFA_VERIFY_URL); + } else if (isPreAuthenticated) { + response.sendRedirect(MFA_VERIFY_URL); + } else { + continueWithDefaultSuccessHandler(request, response, authentication); + } + } + + /** + * If the user account is MFA enabled, the authentication provider would have assigned a role of + * PRE_AUTHENTICATED at this stage. This function verifies that to determine if we need + * redirecting to the verification page + * + * @param authentication the user authentication + * @return true if PRE_AUTHENTICATED + */ + protected boolean isPreAuthenticated(final Authentication authentication) { + final Collection authorities = authentication.getAuthorities(); + for (final GrantedAuthority grantedAuthority : authorities) { + String authorityName = grantedAuthority.getAuthority(); + if (authorityName.equals(Authorities.ROLE_PRE_AUTHENTICATED.getAuthority())) { + return true; + } + } + + return false; + } + + /** + * This calls the normal success handler if the user does not have MFA enabled. + * + * @param request + * @param response + * @param auth the user authentication + * @throws IOException + * @throws ServletException + */ + protected void continueWithDefaultSuccessHandler(HttpServletRequest request, + HttpServletResponse response, Authentication auth) throws IOException, ServletException { + + AuthenticationSuccessHandler delegate = + new RootIsDashboardSuccessHandler(iamBaseUrl, new HttpSessionRequestCache()); + + EnforceAupSignatureSuccessHandler handler = new EnforceAupSignatureSuccessHandler(delegate, + aupSignatureCheckService, accountUtils, accountRepo); + handler.onAuthenticationSuccess(request, response, auth); + } + + protected void clearAuthenticationAttributes(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedAuthenticationFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedAuthenticationFilter.java new file mode 100644 index 000000000..f734d8886 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedAuthenticationFilter.java @@ -0,0 +1,121 @@ +/** + * 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.authn.multi_factor_authentication; + +import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import it.infn.mw.iam.core.ExtendedAuthenticationToken; + +/** + * This replaces the default {@code UsernamePasswordAuthenticationFilter}. It is used to store a new + * {@code ExtendedAuthenticationToken} into the security context instead of a + * {@code UsernamePasswordAuthenticationToken}. + * + *

+ * Ultimately, we want to store information about the methods of authentication used for every login + * attempt. This is useful for registered clients, who may wish to restrict access to certain users + * based on the type or quantity of authentication methods used. The authentication methods are + * passed to the OAuth2 authorization endpoint and stored in the id_token returned to the client. + */ +public class ExtendedAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; + + public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; + + private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher("/login", "POST"); + + private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY; + + private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY; + + private boolean postOnly = true; + + public ExtendedAuthenticationFilter(AuthenticationManager authenticationManager, + AuthenticationSuccessHandler successHandler, AuthenticationFailureHandler failureHandler) { + super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager); + setAuthenticationSuccessHandler(successHandler); + setAuthenticationFailureHandler(failureHandler); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException { + + if (this.postOnly && !request.getMethod().equals("POST")) { + throw new AuthenticationServiceException( + "Authentication method not supported: " + request.getMethod()); + } + String username = obtainUsername(request); + username = (username != null) ? username : ""; + username = username.trim(); + String password = obtainPassword(request); + password = (password != null) ? password : ""; + + ExtendedAuthenticationToken authRequest = new ExtendedAuthenticationToken(username, password); + // Allow subclasses to set the "details" property + setDetails(request, authRequest); + return this.getAuthenticationManager().authenticate(authRequest); + } + + private void setDetails(HttpServletRequest request, ExtendedAuthenticationToken authRequest) { + authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); + } + + /** + * Enables subclasses to override the composition of the password, such as by including additional + * values and a separator. + *

+ * This might be used for example if a postcode/zipcode was required in addition to the password. + * A delimiter such as a pipe (|) should be used to separate the password and extended value(s). + * The AuthenticationDao will need to generate the expected password in a + * corresponding manner. + *

+ * + * @param request so that request attributes can be retrieved + * @return the password that will be presented in the Authentication request token to + * the AuthenticationManager + */ + @Nullable + protected String obtainPassword(HttpServletRequest request) { + return request.getParameter(this.passwordParameter); + } + + /** + * Enables subclasses to override the composition of the username, such as by including additional + * values and a separator. + * + * @param request so that request attributes can be retrieved + * @return the username that will be presented in the Authentication request token to + * the AuthenticationManager + */ + @Nullable + protected String obtainUsername(HttpServletRequest request) { + return request.getParameter(this.usernameParameter); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequest.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequest.java new file mode 100644 index 000000000..3fba3f401 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequest.java @@ -0,0 +1,120 @@ +/** + * 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.authn.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; + +/** + * Represents an extended {@code HttpServletRequest} object. This is primarily used for including + * information in an OAuth2 authorization request about the authentication method(s) used by the + * user to sign in. These are ultimately passed to the token endpoint so they may be included in the + * id_token received by the client. + */ +public final class ExtendedHttpServletRequest extends HttpServletRequestWrapper { + + private final Map queryParameterMap; + private final Charset requestEncoding; + + public ExtendedHttpServletRequest(HttpServletRequest request, String amrClaim) { + super(request); + Map queryMap = getCommonQueryParamFromLegacy(request.getParameterMap()); + queryMap.put(AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING, new String[] {amrClaim}); + queryParameterMap = Collections.unmodifiableMap(queryMap); + + String encoding = request.getCharacterEncoding(); + requestEncoding = (encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8); + } + + private final Map getCommonQueryParamFromLegacy( + Map paramMap) { + Objects.requireNonNull(paramMap); + + return new LinkedHashMap<>(paramMap); + } + + @Override + public String getParameter(String name) { + String[] params = queryParameterMap.get(name); + return params != null ? params[0] : null; + } + + @Override + public String[] getParameterValues(String name) { + return queryParameterMap.get(name); + } + + @Override + public Map getParameterMap() { + return queryParameterMap; // unmodifiable to uphold the interface contract. + } + + @Override + public Enumeration getParameterNames() { + return Collections.enumeration(queryParameterMap.keySet()); + } + + @Override + public String getQueryString() { + // @see : https://stackoverflow.com/a/35831692/9869013 + // return queryParameterMap.entrySet().stream().flatMap(entry -> + // Stream.of(entry.getValue()).map(value -> entry.getKey() + "=" + + // value)).collect(Collectors.joining("&")); // without encoding !! + return queryParameterMap.entrySet() + .stream() + .flatMap(entry -> encodeMultiParameter(entry.getKey(), entry.getValue(), requestEncoding)) + .collect(Collectors.joining("&")); + } + + private Stream encodeMultiParameter(String key, String[] values, Charset encoding) { + return Stream.of(values).map(value -> encodeSingleParameter(key, value, encoding)); + } + + private String encodeSingleParameter(String key, String value, Charset encoding) { + return urlEncode(key, encoding) + "=" + urlEncode(value, encoding); + } + + private String urlEncode(String value, Charset encoding) { + try { + return URLEncoder.encode(value, encoding.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException("Cannot url encode " + value, e); + } + } + + @Override + public ServletInputStream getInputStream() throws IOException { + throw new UnsupportedOperationException("getInputStream() is not implemented in this " + + HttpServletRequest.class.getSimpleName() + " wrapper"); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java new file mode 100644 index 000000000..eb567824c --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java @@ -0,0 +1,93 @@ +/** + * 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.authn.multi_factor_authentication; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Set; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.GenericFilterBean; + +import it.infn.mw.iam.core.ExtendedAuthenticationToken; + +/** + * This filter is applied after authentication has taken place. It is used in the OAuth2 process to + * detect if a set of {@code IamAuthenticationMethodReference} objects are included in the current + * {@code Authentication} object. If so, these are passed to an {@code ExtendedHttpServletRequest} + * so they may be included in the authorization request and passed to OAuth2 clients. + */ +public class ExtendedHttpServletRequestFilter extends GenericFilterBean { + + public static final String AUTHORIZATION_REQUEST_INCLUDES_AMR = + "AUTHORIZATION_REQUEST_INCLUDES_AMR"; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + // We fetch the ExtendedAuthenticationToken from the security context. This contains the + // authentication method references we want to include in the authorization request + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + // Checking to see if this filter has been applied already (if so, this attribute will have + // already been set) + Object amrAttribute = request.getAttribute(AUTHORIZATION_REQUEST_INCLUDES_AMR); + + if (amrAttribute == null && auth instanceof ExtendedAuthenticationToken) { + Set amrSet = + ((ExtendedAuthenticationToken) auth).getAuthenticationMethodReferences(); + String amrClaim = parseAuthenticationMethodReferences(amrSet); + + ExtendedHttpServletRequest extendedRequest = + new ExtendedHttpServletRequest((HttpServletRequest) request, amrClaim); + + extendedRequest.setAttribute(AUTHORIZATION_REQUEST_INCLUDES_AMR, Boolean.TRUE); + request = extendedRequest; + } + + chain.doFilter(request, response); + } + + /** + * Convert a set of authentication method references into a request parameter string Values are + * separated with a + symbol + * + * @param amrSet the set of authentication method references + * @return the parsed string + */ + private String parseAuthenticationMethodReferences(Set amrSet) { + String amrClaim = ""; + Iterator it = amrSet.iterator(); + while (it.hasNext()) { + IamAuthenticationMethodReference current = it.next(); + StringBuilder amrClaimBuilder = new StringBuilder(amrClaim); + amrClaimBuilder.append(current.getName()).append("+"); + amrClaim = amrClaimBuilder.toString(); + } + + // Remove trailing + symbol at end of string + amrClaim = amrClaim.substring(0, amrClaim.length() - 1); + return amrClaim; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java new file mode 100644 index 000000000..491d08afd --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java @@ -0,0 +1,56 @@ +/** + * 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.authn.multi_factor_authentication; + +import java.io.Serializable; + +public class IamAuthenticationMethodReference implements Serializable { + + private static final long serialVersionUID = 1L; + public static final String AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING = "amr"; + + public enum AuthenticationMethodReferenceValues { + // Add additional values here if new authentication factors get added, e.g. HARDWARE_KEY("hwk") + // Consult here for standardised reference values - + // https://datatracker.ietf.org/doc/html/rfc8176 + + PASSWORD("pwd"), ONE_TIME_PASSWORD("otp"); + + private final String value; + + private AuthenticationMethodReferenceValues(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + private String name; + + public IamAuthenticationMethodReference(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java new file mode 100644 index 000000000..258528983 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java @@ -0,0 +1,92 @@ +/** + * 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.authn.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; + +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.*; +import it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsDTO; +import it.infn.mw.iam.api.common.ErrorDTO; +import it.infn.mw.iam.api.common.NoSuchAccountError; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; + +/** + * Presents the step-up authentication page for verifying identity after successful username + + * password authentication. Only accessible if the user is pre-authenticated, i.e. has authenticated + * with username + password but not fully authenticated yet + */ +@Controller +@RequestMapping(MFA_VERIFY_URL) +public class MfaVerifyController { + + public static final String MFA_VERIFY_URL = "/iam/verify"; + final IamAccountRepository accountRepository; + final IamTotpMfaRepository totpMfaRepository; + + public MfaVerifyController(IamAccountRepository accountRepository, + IamTotpMfaRepository totpMfaRepository) { + this.accountRepository = accountRepository; + this.totpMfaRepository = totpMfaRepository; + } + + @PreAuthorize("hasRole('PRE_AUTHENTICATED')") + @GetMapping("") + public String getVerifyMfaView(Authentication authentication, ModelMap model) { + IamAccount account = accountRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> NoSuchAccountError.forUsername(authentication.getName())); + MultiFactorSettingsDTO dto = populateMfaSettings(account); + model.addAttribute("factors", dto.toJson()); + + return "iam/verify-mfa"; + } + + /** + * Populates a DTO containing info on which additional factors of authentication are active + * + * @param account the MFA-enabled account + * @return DTO with populated settings + */ + private MultiFactorSettingsDTO populateMfaSettings(IamAccount account) { + MultiFactorSettingsDTO dto = new MultiFactorSettingsDTO(); + + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (totpMfaOptional.isPresent()) { + IamTotpMfa totpMfa = totpMfaOptional.get(); + dto.setAuthenticatorAppActive(totpMfa.isActive()); + } else { + dto.setAuthenticatorAppActive(false); + } + + return dto; + } + + @ResponseStatus(code = HttpStatus.BAD_REQUEST) + @ExceptionHandler(NoSuchAccountError.class) + @ResponseBody + public ErrorDTO handleNoSuchAccountError(NoSuchAccountError e) { + return ErrorDTO.fromString(e.getMessage()); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java new file mode 100644 index 000000000..e761c5a22 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java @@ -0,0 +1,94 @@ +/** + * 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.authn.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.ONE_TIME_PASSWORD; + +import java.util.Set; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; + +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; + +/** + * Grants full authentication by verifying a provided MFA TOTP. Only comes into play in the step-up + * authentication flow. + */ +public class MultiFactorTotpCheckProvider implements AuthenticationProvider { + + private final IamAccountRepository accountRepo; + private final IamTotpMfaService totpMfaService; + + public MultiFactorTotpCheckProvider(IamAccountRepository accountRepo, + IamTotpMfaService totpMfaService) { + this.accountRepo = accountRepo; + this.totpMfaService = totpMfaService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + ExtendedAuthenticationToken token = (ExtendedAuthenticationToken) authentication; + + String totp = token.getTotp(); + if (totp == null) { + return null; + } + + IamAccount account = accountRepo.findByUsername(authentication.getName()) + .orElseThrow(() -> new BadCredentialsException("Invalid login details")); + + boolean valid = false; + + try { + valid = totpMfaService.verifyTotp(account, totp); + } catch (MfaSecretNotFoundException e) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + if (!valid) { + throw new BadCredentialsException("Bad TOTP"); + } + + return createSuccessfulAuthentication(token); + } + + protected Authentication createSuccessfulAuthentication(ExtendedAuthenticationToken token) { + IamAuthenticationMethodReference otp = + new IamAuthenticationMethodReference(ONE_TIME_PASSWORD.getValue()); + Set refs = token.getAuthenticationMethodReferences(); + refs.add(otp); + token.setAuthenticationMethodReferences(refs); + + ExtendedAuthenticationToken newToken = new ExtendedAuthenticationToken(token.getPrincipal(), + token.getCredentials(), token.getFullyAuthenticatedAuthorities()); + newToken.setAuthenticationMethodReferences(token.getAuthenticationMethodReferences()); + newToken.setAuthenticated(true); + + return newToken; + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(ExtendedAuthenticationToken.class); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.java new file mode 100644 index 000000000..b2884b803 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.java @@ -0,0 +1,118 @@ +/** + * 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.authn.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; + +import java.io.IOException; +import java.nio.file.ProviderNotFoundException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import it.infn.mw.iam.core.ExtendedAuthenticationToken; + +/** + * Used in the MFA verification flow. Receives either a TOTP and constructs the + * authentication request with this parameter. The request is passed to dedicated authentication + * providers which will create the full authentication or raise the appropriate exception + */ +public class MultiFactorVerificationFilter extends AbstractAuthenticationProcessingFilter { + + public static final String TOTP_MFA_CODE_KEY = "totp"; + public static final String TOTP_VERIFIED = "TOTP_VERIFIED"; + + public static final AntPathRequestMatcher DEFAULT_MFA_VERIFY_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(MFA_VERIFY_URL, "POST"); + + private static final boolean postOnly = true; + + private String totpParameter = TOTP_MFA_CODE_KEY; + + public MultiFactorVerificationFilter(AuthenticationManager authenticationManager, + AuthenticationSuccessHandler successHandler, AuthenticationFailureHandler failureHandler) { + super(DEFAULT_MFA_VERIFY_ANT_PATH_REQUEST_MATCHER, authenticationManager); + setAuthenticationSuccessHandler(successHandler); + setAuthenticationFailureHandler(failureHandler); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) throws AuthenticationException { + if (postOnly && !request.getMethod().equals("POST")) { + throw new AuthenticationServiceException( + "Authentication method not supported: " + request.getMethod()); + } + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (!(auth instanceof ExtendedAuthenticationToken)) { + throw new AuthenticationServiceException("Bad authentication"); + } + + ExtendedAuthenticationToken authRequest = (ExtendedAuthenticationToken) auth; + + // Parse TOTP from request (only one should be set) + String totp = parseTotp(request); + + if (totp != null) { + authRequest.setTotp(totp); + } else { + throw new ProviderNotFoundException("No valid totp code was received"); + } + + Authentication fullAuthentication = this.getAuthenticationManager().authenticate(authRequest); + if (fullAuthentication == null) { + throw new ProviderNotFoundException("No valid totp code was received"); + } + + if (authRequest.getTotp() != null) { + request.setAttribute(TOTP_VERIFIED, Boolean.TRUE); + } + + return fullAuthentication; + } + + /** + * Overriding default method because we don't want to invalidate authentication. Doing so would + * remove our PRE_AUTHENTICATED role, which would kick us out of the verification process + */ + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, + HttpServletResponse response, AuthenticationException failed) + throws IOException, ServletException { + this.logger.trace("Failed to process authentication request", failed); + this.logger.trace("Handling authentication failure"); + this.getRememberMeServices().loginFail(request, response); + this.getFailureHandler().onAuthenticationFailure(request, response, failed); + } + + private String parseTotp(HttpServletRequest request) { + String totp = request.getParameter(this.totpParameter); + return totp != null ? totp.trim() : null; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java new file mode 100644 index 000000000..55b850282 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java @@ -0,0 +1,95 @@ +/** + * 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.authn.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; +import static it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter.TOTP_VERIFIED; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.WebAttributes; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.HttpSessionRequestCache; + +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.authn.EnforceAupSignatureSuccessHandler; +import it.infn.mw.iam.authn.RootIsDashboardSuccessHandler; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; + +public class MultiFactorVerificationSuccessHandler implements AuthenticationSuccessHandler { + + private static final Logger logger = LoggerFactory.getLogger(MultiFactorVerificationSuccessHandler.class); + + private final AccountUtils accountUtils; + private final AUPSignatureCheckService aupSignatureCheckService; + private final IamAccountRepository accountRepo; + private final String iamBaseUrl; + + public MultiFactorVerificationSuccessHandler(AccountUtils accountUtils, + AUPSignatureCheckService aupSignatureCheckService, IamAccountRepository accountRepo, + String iamBaseUrl) { + this.accountUtils = accountUtils; + this.aupSignatureCheckService = aupSignatureCheckService; + this.accountRepo = accountRepo; + this.iamBaseUrl = iamBaseUrl; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + handle(request, response, authentication); + clearAuthenticationAttributes(request); + } + + private void handle(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + if (response.isCommitted()) { + logger.warn("Response has already been committed. Unable to redirect to " + MFA_VERIFY_URL); + } else { + continueWithDefaultSuccessHandler(request, response, authentication); + } + } + + private void continueWithDefaultSuccessHandler(HttpServletRequest request, + HttpServletResponse response, Authentication auth) throws IOException, ServletException { + + AuthenticationSuccessHandler delegate = + new RootIsDashboardSuccessHandler(iamBaseUrl, new HttpSessionRequestCache()); + + EnforceAupSignatureSuccessHandler handler = new EnforceAupSignatureSuccessHandler(delegate, + aupSignatureCheckService, accountUtils, accountRepo); + handler.onAuthenticationSuccess(request, response, auth); + } + + protected void clearAuthenticationAttributes(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION); + request.removeAttribute(TOTP_VERIFIED); + } +} + diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/util/Authorities.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/util/Authorities.java index 5586ebfe4..20aa75445 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/authn/util/Authorities.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/util/Authorities.java @@ -24,6 +24,8 @@ public class Authorities { public static final GrantedAuthority ROLE_ADMIN = new SimpleGrantedAuthority("ROLE_ADMIN"); public static final GrantedAuthority ROLE_USER = new SimpleGrantedAuthority("ROLE_USER"); public static final GrantedAuthority ROLE_CLIENT = new SimpleGrantedAuthority("ROLE_CLIENT"); + public static final GrantedAuthority ROLE_PRE_AUTHENTICATED = + new SimpleGrantedAuthority("ROLE_PRE_AUTHENTICATED"); private Authorities() { // prevent instantiation diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java index c6dd8f671..e4ec96528 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java @@ -28,22 +28,18 @@ import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType; import it.infn.mw.iam.config.login.LoginButtonProperties; +import it.infn.mw.iam.config.multi_factor_authentication.VerifyButtonProperties; @Component @ConfigurationProperties(prefix = "iam") public class IamProperties { public enum EditableFields { - NAME, - SURNAME, - EMAIL, - PICTURE + NAME, SURNAME, EMAIL, PICTURE } public enum LocalAuthenticationAllowedUsers { - ALL, - VO_ADMINS, - NONE + ALL, VO_ADMINS, NONE } public enum LoginPageLayoutOptions { @@ -52,9 +48,7 @@ public enum LoginPageLayoutOptions { } public enum LocalAuthenticationLoginPageMode { - VISIBLE, - HIDDEN, - HIDDEN_WITH_LINK + VISIBLE, HIDDEN, HIDDEN_WITH_LINK } public static class AccountLinkingProperties { @@ -612,6 +606,8 @@ public void setEnrollment(String enrollment) { private LoginButtonProperties loginButton = new LoginButtonProperties(); + private VerifyButtonProperties verifyButton = new VerifyButtonProperties(); + private RegistractionAccessToken token = new RegistractionAccessToken(); private PrivacyPolicy privacyPolicy = new PrivacyPolicy(); @@ -718,6 +714,14 @@ public void setLoginButton(LoginButtonProperties loginButton) { this.loginButton = loginButton; } + public VerifyButtonProperties getVerifyButton() { + return verifyButton; + } + + public void setVerifyButton(VerifyButtonProperties verifyButton) { + this.verifyButton = verifyButton; + } + public void setPrivacyPolicy(PrivacyPolicy privacyPolicy) { this.privacyPolicy = privacyPolicy; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java new file mode 100644 index 000000000..f760a760a --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java @@ -0,0 +1,142 @@ +/** + * 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.config; + +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; + +import java.util.Arrays; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; + +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.qr.QrGenerator; +import dev.samstevens.totp.qr.ZxingPngQrGenerator; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorTotpCheckProvider; +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter; +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationSuccessHandler; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; + +/** + * Beans for handling TOTP MFA functionality + */ +@Configuration +public class IamTotpMfaConfig { + + @Value("${iam.baseUrl}") + private String iamBaseUrl; + + @Autowired + private IamAccountRepository accountRepo; + + @Autowired + private AUPSignatureCheckService aupSignatureCheckService; + + @Autowired + private AccountUtils accountUtils; + + /** + * Responsible for generating new TOTP secrets + * + * @return SecretGenerator + */ + @Bean + @Qualifier("secretGenerator") + SecretGenerator secretGenerator() { + return new DefaultSecretGenerator(); + } + + + /** + * Responsible for generating QR code data URI strings from given input parameters, e.g. TOTP + * secret, issuer, etc. + * + * @return QrGenerator + */ + @Bean + @Qualifier("qrGenerator") + QrGenerator qrGenerator() { + return new ZxingPngQrGenerator(); + } + + + /** + * Generates a TOTP from an MFA secret and verifies a user-provided TOTP matches it + * + * @return CodeVerifier + */ + @Bean + @Qualifier("codeVerifier") + CodeVerifier codeVerifier() { + return new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider()); + } + + @Bean(name = "MultiFactorVerificationFilter") + MultiFactorVerificationFilter multiFactorVerificationFilter( + @Qualifier("MultiFactorVerificationAuthenticationManager") AuthenticationManager authenticationManager) { + + return new MultiFactorVerificationFilter(authenticationManager, successHandler(), + failureHandler()); + } + + /** + * Authentication manager for the MFA verification process + * + * @param totpCheckProvider checks a provided TOTP + * @return a new provider manager + */ + @Bean(name = "MultiFactorVerificationAuthenticationManager") + AuthenticationManager authenticationManager(MultiFactorTotpCheckProvider totpCheckProvider) { + return new ProviderManager(Arrays.asList(totpCheckProvider)); + } + + public AuthenticationSuccessHandler successHandler() { + return new MultiFactorVerificationSuccessHandler(accountUtils, aupSignatureCheckService, + accountRepo, iamBaseUrl); + } + + /** + * If we can't verify the user in step-up authentication, redirect back to the /verify endpoint + * with an error param + * + * @return failure handler to redirect to /verify endpoint + */ + public AuthenticationFailureHandler failureHandler() { + return new SimpleUrlAuthenticationFailureHandler(MFA_VERIFY_URL + "?error=failure"); + } + + @Bean + MultiFactorTotpCheckProvider totpCheckProvider(IamTotpMfaService totpMfaService) { + return new MultiFactorTotpCheckProvider(accountRepo, totpMfaService); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/IamTotpMfaProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/IamTotpMfaProperties.java new file mode 100644 index 000000000..3168c99e4 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/IamTotpMfaProperties.java @@ -0,0 +1,43 @@ +/** + * 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.config.mfa; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "mfa") +public class IamTotpMfaProperties { + + private boolean multiFactorSettingsBtnEnabled; + private String passwordToEncryptAndDecrypt; + + public String getPasswordToEncryptOrDecrypt() { + return passwordToEncryptAndDecrypt; + } + + public void setPasswordToEncryptAndDecrypt(String passwordToEncryptAndDecrypt) { + this.passwordToEncryptAndDecrypt = passwordToEncryptAndDecrypt; + } + + public void setMultiFactorSettingsBtnEnabled(boolean multiFactorSettingsBtnEnabled) { + this.multiFactorSettingsBtnEnabled = multiFactorSettingsBtnEnabled; + } + + public boolean hasMultiFactorSettingsBtnEnabled() { + return multiFactorSettingsBtnEnabled; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/multi_factor_authentication/VerifyButtonProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/multi_factor_authentication/VerifyButtonProperties.java new file mode 100644 index 000000000..24dca1e20 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/multi_factor_authentication/VerifyButtonProperties.java @@ -0,0 +1,68 @@ +/** + * 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.config.multi_factor_authentication; + +import javax.validation.constraints.NotBlank; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * Verify button that appears on the MFA verification page + */ +@JsonInclude(Include.NON_EMPTY) +public class VerifyButtonProperties { + private String text; + + private String title; + + @NotBlank + private String style = "btn-verify"; + + private boolean visible = true; + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getStyle() { + return style; + } + + public void setStyle(String style) { + this.style = style; + } + + public boolean isVisible() { + return visible; + } + + public void setVisible(boolean visible) { + this.visible = visible; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java index b9eeaa52d..66b7bdb63 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java @@ -17,6 +17,7 @@ import static it.infn.mw.iam.authn.ExternalAuthenticationHandlerSupport.EXT_AUTHN_UNREGISTERED_USER_AUTH; import static it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType.OIDC; +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; import javax.servlet.RequestDispatcher; @@ -41,18 +42,22 @@ import org.springframework.security.oauth2.provider.expression.OAuth2WebSecurityExpressionHandler; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.context.SecurityContextPersistenceFilter; -import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.filter.GenericFilterBean; import it.infn.mw.iam.api.account.AccountUtils; -import it.infn.mw.iam.authn.EnforceAupSignatureSuccessHandler; +import it.infn.mw.iam.authn.CheckMultiFactorIsEnabledSuccessHandler; import it.infn.mw.iam.authn.ExternalAuthenticationHintService; import it.infn.mw.iam.authn.HintAwareAuthenticationEntryPoint; -import it.infn.mw.iam.authn.RootIsDashboardSuccessHandler; +import it.infn.mw.iam.authn.multi_factor_authentication.ExtendedAuthenticationFilter; +import it.infn.mw.iam.authn.multi_factor_authentication.ExtendedHttpServletRequestFilter; +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter; import it.infn.mw.iam.authn.oidc.OidcAuthenticationProvider; import it.infn.mw.iam.authn.oidc.OidcClientFilter; import it.infn.mw.iam.authn.x509.IamX509AuthenticationProvider; @@ -62,14 +67,13 @@ import it.infn.mw.iam.config.IamProperties; import it.infn.mw.iam.core.IamLocalAuthenticationProvider; import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; import it.infn.mw.iam.service.aup.AUPSignatureCheckService; @SuppressWarnings("deprecation") @Configuration @EnableWebSecurity public class IamWebSecurityConfig { - - @Bean public SecurityEvaluationContextExtension contextExtension() { @@ -105,6 +109,9 @@ public static class UserLoginConfig extends WebSecurityConfigurerAdapter { @Autowired private IamAccountRepository accountRepo; + + @Autowired + private IamTotpMfaRepository totpMfaRepository; @Autowired private AUPSignatureCheckService aupSignatureCheckService; @@ -121,7 +128,7 @@ public static class UserLoginConfig extends WebSecurityConfigurerAdapter { @Autowired public void configureGlobal(final AuthenticationManagerBuilder auth) throws Exception { // @formatter:off - auth.authenticationProvider(new IamLocalAuthenticationProvider(iamProperties, iamUserDetailsService, passwordEncoder)); + auth.authenticationProvider(new IamLocalAuthenticationProvider(iamProperties, iamUserDetailsService, passwordEncoder, accountRepo, totpMfaRepository)); // @formatter:on } @@ -175,6 +182,12 @@ protected void configure(final HttpSecurity http) throws Exception { .authenticationEntryPoint(entryPoint()) .and() .addFilterBefore(authorizationRequestFilter, SecurityContextPersistenceFilter.class) + + // Need to replace the UsernamePasswordAuthenticationFilter because we are now making use of the ExtendedAuthenticationToken globally + .addFilterAt(extendedAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + + // Applied in the OAuth2 login flow + .addFilterAfter(extendedHttpServletRequestFilter(), UsernamePasswordAuthenticationFilter.class) .logout() .logoutUrl("/logout") .and().anonymous() @@ -190,19 +203,29 @@ public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler() { return new OAuth2WebSecurityExpressionHandler(); } + public ExtendedAuthenticationFilter extendedAuthenticationFilter() throws Exception { + return new ExtendedAuthenticationFilter(this.authenticationManager(), successHandler(), + failureHandler()); + } + + public ExtendedHttpServletRequestFilter extendedHttpServletRequestFilter() { + return new ExtendedHttpServletRequestFilter(); + } + public AuthenticationSuccessHandler successHandler() { - AuthenticationSuccessHandler delegate = - new RootIsDashboardSuccessHandler(iamBaseUrl, new HttpSessionRequestCache()); + return new CheckMultiFactorIsEnabledSuccessHandler(accountUtils, iamBaseUrl, + aupSignatureCheckService, accountRepo); + } - return new EnforceAupSignatureSuccessHandler(delegate, aupSignatureCheckService, accountUtils, - accountRepo); + public AuthenticationFailureHandler failureHandler() { + return new SimpleUrlAuthenticationFailureHandler("/login?error=failure"); } } @Configuration @Order(101) public static class RegistrationConfig extends WebSecurityConfigurerAdapter { - + public static final String START_REGISTRATION_ENDPOINT = "/start-registration"; @Autowired @@ -326,4 +349,37 @@ public void configure(final WebSecurity builder) throws Exception { builder.debug(true); } } + + /** + * Configure the login flow for the step-up authentication. This takes place at the /iam/verify + * endpoint + */ + @Configuration + @Order(102) + public static class MultiFactorConfigurationAdapter extends WebSecurityConfigurerAdapter { + + @Autowired + @Qualifier("MultiFactorVerificationFilter") + private MultiFactorVerificationFilter multiFactorVerificationFilter; + + public AuthenticationEntryPoint mfaAuthenticationEntryPoint() { + return new LoginUrlAuthenticationEntryPoint(MFA_VERIFY_URL); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.antMatcher(MFA_VERIFY_URL + "**") + .authorizeRequests() + .anyRequest() + .hasRole("PRE_AUTHENTICATED") + .and() + .formLogin() + .failureUrl(MFA_VERIFY_URL + "?error=failure") + .and() + .exceptionHandling() + .authenticationEntryPoint(mfaAuthenticationEntryPoint()) + .and() + .addFilterAt(multiFactorVerificationFilter, UsernamePasswordAuthenticationFilter.class); + } + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java index 62abcacbe..d84606927 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java @@ -119,7 +119,6 @@ public static class RegisterEndpointAuthorizationConfig extends WebSecurityConfi @Autowired private OAuth2AuthenticationEntryPoint authenticationEntryPoint; - @Override public void configure(final HttpSecurity http) throws Exception { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java new file mode 100644 index 000000000..86950af77 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java @@ -0,0 +1,145 @@ +/** + * 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; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference; + +/** + *

+ * An extended auth token that functions the same as a {@code UsernamePasswordAuthenticationToken} + * but with some additional fields detailing more information about the methods of authentication + * used. + * + *

+ * The additional information includes: + * + *

    + *
  • {@code Set + *
  • {@code String totp} - if authenticating with a TOTP, this field is set
  • + *
  • {@code fullyAuthenticatedAuthorities} - the authorities the user will be granted if full + * authentication takes place. If an MFA user has only authenticated with a username and password so + * far, they will only officially have an authority of PRE_AUTHENTICATED + *
+ */ +public class ExtendedAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = 1L; + private transient Object principal; + private transient Object credentials; + private Set authenticationMethodReferences = new HashSet<>(); + private String totp; + private Set fullyAuthenticatedAuthorities; + + public ExtendedAuthenticationToken(Object principal, Object credentials) { + super(null); + this.principal = principal; + this.credentials = credentials; + } + + public ExtendedAuthenticationToken(Object principal, Object credentials, + Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + } + + public Set getFullyAuthenticatedAuthorities() { + return fullyAuthenticatedAuthorities; + } + + public void setFullyAuthenticatedAuthorities( + Set fullyAuthenticatedAuthorities) { + this.fullyAuthenticatedAuthorities = fullyAuthenticatedAuthorities; + } + + public Set getAuthenticationMethodReferences() { + return authenticationMethodReferences; + } + + public void setAuthenticationMethodReferences( + Set authenticationMethodReferences) { + this.authenticationMethodReferences = authenticationMethodReferences; + } + + public String getTotp() { + return totp; + } + + public void setTotp(String totp) { + this.totp = totp; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ExtendedAuthenticationToken)) { + return false; + } + if (!super.equals(obj)) { + return false; + } + ExtendedAuthenticationToken that = (ExtendedAuthenticationToken) obj; + + return Objects.equals(this.principal, that.principal) + && Objects.equals(this.credentials, that.credentials) + && Objects.equals(this.authenticationMethodReferences, that.authenticationMethodReferences) + && Objects.equals(this.totp, that.totp) + && Objects.equals(this.fullyAuthenticatedAuthorities, that.fullyAuthenticatedAuthorities); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), principal, credentials, authenticationMethodReferences, + totp, fullyAuthenticatedAuthorities); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(getClass().getSimpleName()).append(" ["); + sb.append("Principal=").append(getPrincipal()).append(", "); + sb.append("Credentials=[PROTECTED], "); + sb.append("Authenticated=").append(isAuthenticated()).append(", "); + sb.append("Details=").append(getDetails()).append(", "); + sb.append("Granted Authorities=").append(this.getAuthorities()).append(", "); + sb.append("Authentication Method References=").append(this.getAuthenticationMethodReferences()); + sb.append("TOTP=").append(this.getTotp()); + sb.append("]"); + return sb.toString(); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/IamLocalAuthenticationProvider.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/IamLocalAuthenticationProvider.java index 7f03bad7a..aa782a5dd 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/IamLocalAuthenticationProvider.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/IamLocalAuthenticationProvider.java @@ -15,42 +15,123 @@ */ package it.infn.mw.iam.core; +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.PASSWORD; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.function.Predicate; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; +import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference; +import it.infn.mw.iam.authn.util.Authorities; import it.infn.mw.iam.config.IamProperties; import it.infn.mw.iam.config.IamProperties.LocalAuthenticationAllowedUsers; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; public class IamLocalAuthenticationProvider extends DaoAuthenticationProvider { - public static final Logger LOG = LoggerFactory.getLogger(IamLocalAuthenticationProvider.class); - public static final String DISABLED_AUTH_MESSAGE = "Local authentication is disabled"; private final LocalAuthenticationAllowedUsers allowedUsers; + private final IamAccountRepository accountRepo; + private final IamTotpMfaRepository totpMfaRepository; private static final Predicate ADMIN_MATCHER = a -> a.getAuthority().equals("ROLE_ADMIN"); public IamLocalAuthenticationProvider(IamProperties properties, UserDetailsService uds, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, IamAccountRepository accountRepo, + IamTotpMfaRepository totpMfaRepository) { this.allowedUsers = properties.getLocalAuthn().getEnabledFor(); setUserDetailsService(uds); setPasswordEncoder(passwordEncoder); + this.accountRepo = accountRepo; + this.totpMfaRepository = totpMfaRepository; + } + + /** + *

+ * Overriding this to accommodate the ExtendedAuthenticationToken. + * + *

+ * First, we authenticate the username and password. Then we check if MFA is enabled on the + * account. If so, we set a {@code PRE_AUTHENTICATED} role on the user so they may be navigated to + * an additional authentication step. Otherwise, create a full authentication object. + */ + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + // The first step is to validate the default login credentials. Therefore, we convert the + // authentication to a UsernamePasswordAuthenticationToken and super(authenticate) in the + // default manner + UsernamePasswordAuthenticationToken userpassToken = new UsernamePasswordAuthenticationToken( + authentication.getPrincipal(), authentication.getCredentials()); + authentication = super.authenticate(userpassToken); + + IamAccount account = accountRepo.findByUsername(authentication.getName()) + .orElseThrow(() -> new BadCredentialsException("Invalid login details")); + + ExtendedAuthenticationToken token; + + // We have just completed an authentication with the user's password. Therefore, we add "pwd" to + // the list of authentication method references. + IamAuthenticationMethodReference pwd = + new IamAuthenticationMethodReference(PASSWORD.getValue()); + Set refs = new HashSet<>(); + refs.add(pwd); + + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + + // Checking to see if we can find an active MFA secret attached to the user's account. If so, + // MFA is enabled on the account + if (totpMfaOptional.isPresent() && totpMfaOptional.get().isActive()) { + List currentAuthorities = new ArrayList<>(); + // Add PRE_AUTHENTICATED role to the user. This grants them access to the /iam/verify endpoint + currentAuthorities.add(Authorities.ROLE_PRE_AUTHENTICATED); + + // Retrieve the authorities that are assigned to this user when they are fully authenticated + Set fullyAuthenticatedAuthorities = new HashSet<>(); + for (GrantedAuthority a : authentication.getAuthorities()) { + fullyAuthenticatedAuthorities.add(a); + } + + // Construct a new authentication object for the PRE_AUTHENTICATED user. + token = new ExtendedAuthenticationToken(authentication.getPrincipal(), + authentication.getCredentials(), currentAuthorities); + token.setAuthenticated(false); + token.setAuthenticationMethodReferences(refs); + token.setFullyAuthenticatedAuthorities(fullyAuthenticatedAuthorities); + } else { + // MFA is not enabled on this account, construct a new authentication object for the FULLY + // AUTHENTICATED user, granting their normal authorities + token = new ExtendedAuthenticationToken(authentication.getPrincipal(), + authentication.getCredentials(), authentication.getAuthorities()); + token.setAuthenticationMethodReferences(refs); + token.setAuthenticated(true); + } + + return token; } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, - UsernamePasswordAuthenticationToken authentication) { + UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { super.additionalAuthenticationChecks(userDetails, authentication); if (LocalAuthenticationAllowedUsers.NONE.equals(allowedUsers) @@ -60,4 +141,8 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, } } + @Override + public boolean supports(Class authentication) { + return (ExtendedAuthenticationToken.class.isAssignableFrom(authentication)); + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java index 3544079e0..734fb2e85 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java @@ -29,6 +29,7 @@ import it.infn.mw.iam.persistence.model.IamLabel; import it.infn.mw.iam.persistence.repository.IamAccountRepository; +@SuppressWarnings("deprecation") public abstract class BaseIdTokenCustomizer implements IDTokenCustomizer { private final IamAccountRepository accountRepo; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java index f1a064ea7..4509a44d4 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java @@ -75,6 +75,11 @@ public JWTClaimsSet buildAccessToken(OAuth2AccessTokenEntity token, if (properties.getAccessToken().isIncludeAuthnInfo()) { addAuthnInfoClaims(builder, token.getScope(), userInfo); } + + if (token.getScope().contains(ATTR_SCOPE)) { + builder.claim(ATTR_SCOPE, attributeHelper + .getAttributeMapFromUserInfo(((UserInfoAdapter) userInfo).getUserinfo())); + } } addAudience(builder, authentication); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java index 4384936ae..cbd1cd2e2 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java @@ -34,14 +34,15 @@ */ public interface IamAccountService { - + /** * Finds an account by UUID - * @param uuid + * + * @param uuid * @return an {@link Optional} iam account */ Optional findByUuid(String uuid); - + /** * Creates a new {@link IamAccount}, after some checks. * @@ -122,6 +123,7 @@ public interface IamAccountService { /** * Sets end time for a given account + * * @param account * @param endTime * @return the updated account @@ -130,18 +132,20 @@ public interface IamAccountService { /** * Disables account + * * @param account * @return the updated account */ IamAccount disableAccount(IamAccount account); - + /** * Restores account + * * @param account * @return the updated account */ IamAccount restoreAccount(IamAccount account); - + /** * Sets an attribute for the account * diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretAlreadyBoundException.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretAlreadyBoundException.java new file mode 100644 index 000000000..6da0bd856 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretAlreadyBoundException.java @@ -0,0 +1,25 @@ +/** + * 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.user.exception; + +public class MfaSecretAlreadyBoundException extends IamAccountException { + + private static final long serialVersionUID = 1L; + + public MfaSecretAlreadyBoundException(String message) { + super(message); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretNotFoundException.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretNotFoundException.java new file mode 100644 index 000000000..10c80adcb --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretNotFoundException.java @@ -0,0 +1,27 @@ +/** + * 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.user.exception; + +import org.springframework.security.core.AuthenticationException; + +public class MfaSecretNotFoundException extends AuthenticationException { + + private static final long serialVersionUID = 1L; + + public MfaSecretNotFoundException(String message) { + super(message); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/TotpMfaAlreadyEnabledException.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/TotpMfaAlreadyEnabledException.java new file mode 100644 index 000000000..32b83f501 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/TotpMfaAlreadyEnabledException.java @@ -0,0 +1,25 @@ +/** + * 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.user.exception; + +public class TotpMfaAlreadyEnabledException extends IamAccountException { + + private static final long serialVersionUID = 1L; + + public TotpMfaAlreadyEnabledException(String message) { + super(message); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java index 6b9399298..6cf194a3a 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java @@ -28,6 +28,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; + import org.springframework.web.bind.annotation.RestController; import com.nimbusds.jose.jwk.JWK; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java index 044150624..2ae467ab5 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java @@ -20,7 +20,6 @@ import javax.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.EnvironmentAware; import org.springframework.core.env.Environment; @@ -30,8 +29,9 @@ import com.google.common.base.Strings; import it.infn.mw.iam.config.IamProperties; -import it.infn.mw.iam.config.IamProperties.Logo; import it.infn.mw.iam.config.IamProperties.LoginPageLayout.ExternalAuthnOptions; +import it.infn.mw.iam.config.IamProperties.Logo; +import it.infn.mw.iam.config.mfa.IamTotpMfaProperties; import it.infn.mw.iam.config.oidc.OidcProvider; import it.infn.mw.iam.config.oidc.OidcValidatedProviders; @@ -50,6 +50,7 @@ public class DefaultLoginPageConfiguration implements LoginPageConfiguration, En private boolean localAuthenticationVisible; private boolean showLinkToLocalAuthn; private boolean defaultLoginPageLayout; + private boolean mfaSettingsBtnEnabled; @Value("${iam.account-linking.enable}") private Boolean accountLinkingEnabled; @@ -57,11 +58,15 @@ public class DefaultLoginPageConfiguration implements LoginPageConfiguration, En private OidcValidatedProviders providers; private final IamProperties iamProperties; + private final IamTotpMfaProperties iamTotpMfaProperties; - @Autowired - public DefaultLoginPageConfiguration(OidcValidatedProviders providers, IamProperties properties) { + public DefaultLoginPageConfiguration( + OidcValidatedProviders providers, + IamProperties properties, + IamTotpMfaProperties iamTotpMfaProperties) { this.providers = providers; this.iamProperties = properties; + this.iamTotpMfaProperties = iamTotpMfaProperties; } @@ -78,6 +83,7 @@ public void init() { .equals(iamProperties.getLocalAuthn().getLoginPageVisibility()); defaultLoginPageLayout = IamProperties.LoginPageLayoutOptions.LOGIN_FORM .equals(iamProperties.getLoginPageLayout().getSectionToBeDisplayedFirst()); + mfaSettingsBtnEnabled = iamTotpMfaProperties.hasMultiFactorSettingsBtnEnabled(); } @Override @@ -169,6 +175,10 @@ public boolean isShowLinkToLocalAuthenticationPage() { return showLinkToLocalAuthn; } + @Override + public boolean isMfaSettingsBtnEnabled() { + return mfaSettingsBtnEnabled; + } @Override public boolean isShowRegistrationButton() { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java index 6e516855e..95f146e2e 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java @@ -30,6 +30,8 @@ public interface LoginPageConfiguration { boolean isShowLinkToLocalAuthenticationPage(); + boolean isMfaSettingsBtnEnabled(); + boolean isExternalAuthenticationEnabled(); boolean isOidcEnabled(); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/DefaultMultiFactorVerificationPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/DefaultMultiFactorVerificationPageConfiguration.java new file mode 100644 index 000000000..cf0d39708 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/DefaultMultiFactorVerificationPageConfiguration.java @@ -0,0 +1,55 @@ +/** + * 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.web.multi_factor_authentication; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.config.IamProperties.Logo; + +import com.google.common.base.Strings; + +/** + * Config for the Verify button that appears at the /iam/verify MFA endpoint + */ +@Component +public class DefaultMultiFactorVerificationPageConfiguration + implements MultiFactorVerificationPageConfiguration { + + private final IamProperties iamProperties; + + public static final String DEFAULT_VERIFICATION_BUTTON_TEXT = "Verify"; + + @Autowired + public DefaultMultiFactorVerificationPageConfiguration(IamProperties properties) { + this.iamProperties = properties; + } + + @Override + public Logo getLogo() { + return iamProperties.getLogo(); + } + + @Override + public String getVerifyButtonText() { + if (Strings.isNullOrEmpty(iamProperties.getVerifyButton().getText())) { + return DEFAULT_VERIFICATION_BUTTON_TEXT; + } + return iamProperties.getVerifyButton().getText(); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/MultiFactorVerificationPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/MultiFactorVerificationPageConfiguration.java new file mode 100644 index 000000000..8cf3f49a5 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/MultiFactorVerificationPageConfiguration.java @@ -0,0 +1,28 @@ +/** + * 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.web.multi_factor_authentication; + +import it.infn.mw.iam.config.IamProperties.Logo; + +/** + * Config for the Verify button that appears at the /iam/verify MFA endpoint + */ +public interface MultiFactorVerificationPageConfiguration { + + String getVerifyButtonText(); + + Logo getLogo(); +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java index 06d4e458c..7c11c4328 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java @@ -27,12 +27,15 @@ import it.infn.mw.iam.config.client_registration.ClientRegistrationProperties; import it.infn.mw.iam.config.saml.IamSamlProperties; import it.infn.mw.iam.core.web.loginpage.LoginPageConfiguration; +import it.infn.mw.iam.core.web.multi_factor_authentication.MultiFactorVerificationPageConfiguration; import it.infn.mw.iam.rcauth.RCAuthProperties; @Component public class IamViewInfoInterceptor implements HandlerInterceptor { public static final String LOGIN_PAGE_CONFIGURATION_KEY = "loginPageConfiguration"; + public static final String MULTI_FACTOR_VERIFICATION_KEY = + "multiFactorVerificationPageConfiguration"; public static final String ORGANISATION_NAME_KEY = "iamOrganisationName"; public static final String IAM_SAML_PROPERTIES_KEY = "iamSamlProperties"; public static final String IAM_OIDC_PROPERTIES_KEY = "iamOidcProperties"; @@ -52,13 +55,16 @@ public class IamViewInfoInterceptor implements HandlerInterceptor { @Value("${iam.organisation.name}") String organisationName; - + @Autowired LoginPageConfiguration loginPageConfiguration; + @Autowired + MultiFactorVerificationPageConfiguration multiFactorVerificationPageConfiguration; + @Autowired IamSamlProperties samlProperties; - + @Autowired RCAuthProperties rcAuthProperties; @@ -71,16 +77,18 @@ public class IamViewInfoInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - + request.setAttribute(IAM_VERSION_KEY, iamVersion); request.setAttribute(GIT_COMMIT_ID_KEY, gitCommitId); request.setAttribute(ORGANISATION_NAME_KEY, organisationName); - + request.setAttribute(LOGIN_PAGE_CONFIGURATION_KEY, loginPageConfiguration); - + + request.setAttribute(MULTI_FACTOR_VERIFICATION_KEY, multiFactorVerificationPageConfiguration); + request.setAttribute(IAM_SAML_PROPERTIES_KEY, samlProperties); - + request.setAttribute(RCAUTH_ENABLED_KEY, rcAuthProperties.isEnabled()); request.setAttribute(CLIENT_DEFAULTS_PROPERTIES_KEY, clientRegistrationProperties.getClientDefaults()); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java index 587645ba3..6695cce1a 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java @@ -23,11 +23,11 @@ import org.mitre.openid.connect.view.JsonEntityView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; @@ -49,7 +49,6 @@ public class IamDiscoveryEndpoint { private final UserInfoService userService; private final WellKnownInfoProvider wellKnownInfoProvider; - @Autowired public IamDiscoveryEndpoint(ConfigurationPropertiesBean config, UserInfoService userService, WellKnownInfoProvider wellKnownInfoProvider) { this.config = config; @@ -57,7 +56,7 @@ public IamDiscoveryEndpoint(ConfigurationPropertiesBean config, UserInfoService this.wellKnownInfoProvider = wellKnownInfoProvider; } - @RequestMapping(value = {"/" + WEBFINGER_URL}, method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = {"/" + WEBFINGER_URL}, produces = MediaType.APPLICATION_JSON_VALUE) public String webfinger(@RequestParam("resource") String resource, @RequestParam(value = "rel", required = false) String rel, Model model) { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java b/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java index a16359b05..d41fc667c 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java @@ -57,4 +57,8 @@ IamEmailNotification createClientStatusChangedMessageFor(ClientDetailsEntity cli IamEmailNotification createAccountSuspendedMessage(IamAccount account); IamEmailNotification createAccountRestoredMessage(IamAccount account); + + IamEmailNotification createMfaDisableMessage(IamAccount account); + + IamEmailNotification createMfaEnableMessage(IamAccount account); } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java b/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java index 77ab7c021..01d0280c6 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java @@ -406,6 +406,44 @@ public IamEmailNotification createAccountRestoredMessage(IamAccount account) { } + @Override + public IamEmailNotification createMfaEnableMessage(IamAccount account) { + String recipient = account.getUserInfo().getName(); + + Map model = new HashMap<>(); + model.put(RECIPIENT_FIELD, recipient); + model.put(ORGANISATION_NAME, organisationName); + + String subject = "Multi-factor authentication (MFA) enabled"; + + IamEmailNotification notification = + createMessage("mfaEnable.ftl", model, IamNotificationType.MFA_ENABLE, + subject, asList(account.getUserInfo().getEmail())); + + LOG.debug("Created Multi-factor authentication (MFA) enabled message for the account {}", account.getUuid()); + + return notification; + } + + @Override + public IamEmailNotification createMfaDisableMessage(IamAccount account) { + String recipient = account.getUserInfo().getName(); + + Map model = new HashMap<>(); + model.put(RECIPIENT_FIELD, recipient); + model.put(ORGANISATION_NAME, organisationName); + + String subject = "Multi-factor authentication (MFA) disabled"; + + IamEmailNotification notification = + createMessage("mfaDisable.ftl", model, IamNotificationType.MFA_DISABLE, + subject, asList(account.getUserInfo().getEmail())); + + LOG.debug("Created Multi-factor authentication (MFA) disabled message for the account {}", account.getUuid()); + + return notification; + } + protected IamEmailNotification createMessage(String templateName, Map model, IamNotificationType messageType, String subject, List receiverAddress) { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionHelper.java new file mode 100644 index 000000000..78f0590dc --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionHelper.java @@ -0,0 +1,149 @@ +/** + * 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.util.mfa; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +public class IamTotpMfaEncryptionAndDecryptionHelper { + + public enum AesCipherModes { + CBC("AES/CBC/PKCS5Padding"), + GCM("AES/GCM/NoPadding"); + + private final String cipherMode; + + AesCipherModes(String cipherMode) { + this.cipherMode = cipherMode; + } + + public String getCipherMode() { + return cipherMode; + } + } + + private String encryptionAlgorithm = "AES"; + private AesCipherModes shortFormOfCipherMode = AesCipherModes.GCM; + private String modeOfOperation = shortFormOfCipherMode.getCipherMode(); + + // AES `keySize` has 3 options: 128, 192, or 256 bits. + private int keyLengthInBits = 128; + + private int ivLengthInBytes = 16; + private int tagLengthInBits = 128; + private int ivLengthInBytesForGCM = 12; + + // Multiples of 8 + private int saltLengthInBytes = 16; + + // The higher value the better + private int iterations = 65536; + private Charset utf8 = StandardCharsets.UTF_8; + + private static IamTotpMfaEncryptionAndDecryptionHelper instance; + + private IamTotpMfaEncryptionAndDecryptionHelper() { + // Prevent instantiation + } + + public String getEncryptionAlgorithm() { + return encryptionAlgorithm; + } + + public void setEncryptionAlgorithm(String encryptionAlgorithm) { + this.encryptionAlgorithm = encryptionAlgorithm; + } + + public String getModeOfOperation() { + return modeOfOperation; + } + + public void setModeOfOperation(String modeOfOperation) { + this.modeOfOperation = modeOfOperation; + } + + public int getKeyLengthInBits() { + return keyLengthInBits; + } + + public void setKeyLengthInBits(int keyLengthInBits) { + this.keyLengthInBits = keyLengthInBits; + } + + public int getIvLengthInBytes() { + return ivLengthInBytes; + } + + public void setIvLengthInBytes(int ivLengthInBytes) { + this.ivLengthInBytes = ivLengthInBytes; + } + + public int getTagLengthInBits() { + return tagLengthInBits; + } + + public void setTagLengthInBits(int tagLengthInBits) { + this.tagLengthInBits = tagLengthInBits; + } + + public int getIvLengthInBytesForGCM() { + return ivLengthInBytesForGCM; + } + + public void setIvLengthInBytesForGCM(int ivLengthInBytesForGCM) { + this.ivLengthInBytesForGCM = ivLengthInBytesForGCM; + } + + public int getSaltLengthInBytes() { + return saltLengthInBytes; + } + + public void setSaltLengthInBytes(int saltLengthInBytes) { + this.saltLengthInBytes = saltLengthInBytes; + } + + public int getIterations() { + return iterations; + } + + public void setIterations(int iterations) { + this.iterations = iterations; + } + + public Charset getUtf8() { + return utf8; + } + + public AesCipherModes getShortFormOfCipherMode() { + return shortFormOfCipherMode; + } + + public void setShortFormOfCipherMode(AesCipherModes shortFormOfCipherMode) { + this.shortFormOfCipherMode = shortFormOfCipherMode; + } + + /** + * Helper to get the instance instead of creating new objects, + * acts like a singleton pattern. + */ + public static synchronized IamTotpMfaEncryptionAndDecryptionHelper getInstance() { + if (instance == null) { + instance = new IamTotpMfaEncryptionAndDecryptionHelper(); + } + + return instance; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionUtil.java b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionUtil.java new file mode 100644 index 000000000..e53b6e859 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionUtil.java @@ -0,0 +1,245 @@ +/** + * 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.util.mfa; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.KeySpec; +import java.util.Base64; +import java.nio.ByteBuffer; + +public class IamTotpMfaEncryptionAndDecryptionUtil { + + private static final IamTotpMfaEncryptionAndDecryptionHelper defaultModel = IamTotpMfaEncryptionAndDecryptionHelper + .getInstance(); + + private IamTotpMfaEncryptionAndDecryptionUtil() { + } + + /** + * This helper method requires a password for encrypting the plaintext. + * Ensure to use the same password for decryption as well. + * + * @param plaintext plaintext to encrypt. + * @param password Provided by the admin through the environment + * variable. + * + * @return String If encryption is successful, the cipherText would be returned. + * + * @throws IamTotpMfaInvalidArgumentError + */ + public static String encryptSecret(String plaintext, String password) + throws IamTotpMfaInvalidArgumentError { + String modeOfOperation = defaultModel.getModeOfOperation(); + + if (validateText(plaintext) || validateText(password)) { + throw new IamTotpMfaInvalidArgumentError( + "Please ensure that you provide plaintext and the password"); + } + + try { + byte[] salt = generateNonce(defaultModel.getSaltLengthInBytes()); + Key key = getKeyFromPassword(password, salt, defaultModel.getEncryptionAlgorithm()); + byte[] iv; + + Cipher cipher = Cipher.getInstance(modeOfOperation); + + if (isCipherModeCBC()) { + IvParameterSpec ivParamSpec = getIVSecureRandom(defaultModel.getIvLengthInBytes()); + + cipher.init(Cipher.ENCRYPT_MODE, key, ivParamSpec); + + iv = cipher.getIV(); + } else { + iv = generateNonce(defaultModel.getIvLengthInBytesForGCM()); + + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(defaultModel.getTagLengthInBits(), iv)); + } + + byte[] cipherText = cipher.doFinal(plaintext.getBytes()); + + // Append salt, IV, and cipherText into `encryptedData`. + byte[] encryptedData = ByteBuffer.allocate(salt.length + iv.length + cipherText.length) + .put(salt) + .put(iv) + .put(cipherText) + .array(); + + return Base64.getEncoder() + .encodeToString(encryptedData); + } catch (Exception exp) { + throw new IamTotpMfaInvalidArgumentError( + "An error occurred while encrypting secret", exp); + } + } + + /** + * Helper to decrypt the cipherText. Ensure you use the same password as you did + * during encryption. + * + * @param cText Encrypted data which help us to extract the plaintext. + * @param password Provided by the admin through the environment + * variable. + * + * @return String Returns plainText which we obtained from the cipherText. + * + * @throws IamTotpMfaInvalidArgumentError + */ + public static String decryptSecret(String cText, String password) + throws IamTotpMfaInvalidArgumentError { + String modeOfOperation = defaultModel.getModeOfOperation(); + + if (validateText(cText) || validateText(password)) { + throw new IamTotpMfaInvalidArgumentError( + "Please ensure that you provide cipherText and the password"); + } + + try { + byte[] encryptedData = Base64.getDecoder().decode(cText); + + ByteBuffer byteBuffer = ByteBuffer.wrap(encryptedData); + + // Extract salt, IV, and cipherText from the combined data + byte[] salt = new byte[defaultModel.getSaltLengthInBytes()]; + byteBuffer.get(salt); + + byte[] iv; + + if (isCipherModeCBC()) { + iv = new byte[defaultModel.getIvLengthInBytes()]; + } else { + iv = new byte[defaultModel.getIvLengthInBytesForGCM()]; + } + + byteBuffer.get(iv); + + byte[] cipherText = new byte[byteBuffer.remaining()]; + byteBuffer.get(cipherText); + + Key key = getKeyFromPassword(password, salt, defaultModel.getEncryptionAlgorithm()); + + Cipher cipher = Cipher.getInstance(modeOfOperation); + + if (isCipherModeCBC()) { + cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)); + } else { + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(defaultModel.getTagLengthInBits(), iv)); + } + + byte[] decryptedTextBytes = cipher.doFinal(cipherText); + + return new String(decryptedTextBytes); + } catch (Exception exp) { + throw new IamTotpMfaInvalidArgumentError( + "An error occurred while decrypting ciphertext", exp); + } + } + + /** + * Generates a random Initialization Vector(IV) using a secure random generator. + * + * @param byteSize. Specifies IV length for CBC. + * + * @return IvParameterSpec + * + * @throws NoSuchAlgorithmException + */ + private static IvParameterSpec getIVSecureRandom(int byteSize) + throws NoSuchAlgorithmException { + SecureRandom random = SecureRandom.getInstanceStrong(); + byte[] iv = new byte[byteSize]; + + random.nextBytes(iv); + + return new IvParameterSpec(iv); + } + + /** + * Generates the key which can be used to encrypt and decrypt the plaintext. + * + * @param password Provided by the admin through the environment + * variable. + * @param salt Ensures derived keys to be different. + * @param algorithm A symmetric key algorithm (AES) has been used. + * + * @return SecretKey + * + * @throws NoSuchAlgorithmException + * @throws InvalidKeySpecException + */ + private static SecretKey getKeyFromPassword(String password, byte[] salt, String algorithm) + throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, defaultModel.getIterations(), + defaultModel.getKeyLengthInBits()); + + byte[] calculatedHash = factory.generateSecret(spec).getEncoded(); + byte[] storedHash = factory.generateSecret(spec).getEncoded(); + + if (MessageDigest.isEqual(calculatedHash, storedHash)) { + return new SecretKeySpec(calculatedHash, algorithm); + } else { + throw new IamTotpMfaInvalidArgumentError("Invalid password"); + } + } + + /** + * Generates a random salt using a secure random generator. + * + * @param byteSize Specifies either salt or IV for GCM byte length + * + * @return byte[] + * @throws NoSuchAlgorithmException + */ + private static byte[] generateNonce(int byteSize) throws NoSuchAlgorithmException { + SecureRandom random = SecureRandom.getInstanceStrong(); + byte[] salt = new byte[byteSize]; + + random.nextBytes(salt); + + return salt; + } + + /** + * Helper method to determine whether the provided text is an empty or NULL. + * + * @return boolean + */ + private static boolean validateText(String text) { + return (text == null || text.isEmpty()); + } + + /** + * Helper method to determine whether it is in CBC mode or NOT. + * + * @return boolean + */ + private static boolean isCipherModeCBC() { + return defaultModel.getModeOfOperation().equalsIgnoreCase( + IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.CBC.getCipherMode()); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaInvalidArgumentError.java b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaInvalidArgumentError.java new file mode 100644 index 000000000..34b7b12c3 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaInvalidArgumentError.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.util.mfa; + +public class IamTotpMfaInvalidArgumentError extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public IamTotpMfaInvalidArgumentError(String message) { + super(message); + } + + public IamTotpMfaInvalidArgumentError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/iam-login-service/src/main/resources/application-h2.yml b/iam-login-service/src/main/resources/application-h2.yml index f3128b92c..99fcfa7c4 100644 --- a/iam-login-service/src/main/resources/application-h2.yml +++ b/iam-login-service/src/main/resources/application-h2.yml @@ -27,7 +27,7 @@ spring: locations: - classpath:db/migration/h2 - classpath:db/migration/test - + datasource: type: org.h2.jdbcx.JdbcDataSource url: jdbc:h2:mem:iam;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1 diff --git a/iam-login-service/src/main/resources/application-mfa.yml b/iam-login-service/src/main/resources/application-mfa.yml new file mode 100644 index 000000000..68a1a018b --- /dev/null +++ b/iam-login-service/src/main/resources/application-mfa.yml @@ -0,0 +1,19 @@ +# +# 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. +# + +mfa: + multi-factor-settings-btn-enabled: ${IAM_TOTP_MFA_ENABLE_MFA_SETTINGS_BUTTON:true} + password-to-encrypt-and-decrypt: ${IAM_TOTP_MFA_PASSWORD_TO_ENCRYPT_AND_DECRYPT:define_me_please} diff --git a/iam-login-service/src/main/resources/application-prod.yml b/iam-login-service/src/main/resources/application-prod.yml index f1e9582b7..845329669 100644 --- a/iam-login-service/src/main/resources/application-prod.yml +++ b/iam-login-service/src/main/resources/application-prod.yml @@ -36,4 +36,4 @@ spring: hikari: maximum-pool-size: ${IAM_DB_MAX_ACTIVE:50} minimum-idle: ${IAM_DB_MIN_IDLE:8} - connection-test-query: ${IAM_DB_VALIDATION_QUERY:SELECT 1} \ No newline at end of file + connection-test-query: ${IAM_DB_VALIDATION_QUERY:SELECT 1} diff --git a/iam-login-service/src/main/resources/application.properties b/iam-login-service/src/main/resources/application.properties index 696ab3e43..3face04bf 100644 --- a/iam-login-service/src/main/resources/application.properties +++ b/iam-login-service/src/main/resources/application.properties @@ -39,7 +39,6 @@ logging.level.org.apache.tomcat.util.scan.StandardJarScanner=ERROR # Persistence engine logging logging.level.org.eclipse.persistence=DEBUG - # Notification service logging #logging.level.it.infn.mw.iam.notification=DEBUG diff --git a/iam-login-service/src/main/resources/application.yml b/iam-login-service/src/main/resources/application.yml index 76083b81d..433d5a5cb 100644 --- a/iam-login-service/src/main/resources/application.yml +++ b/iam-login-service/src/main/resources/application.yml @@ -25,6 +25,7 @@ server: port: ${IAM_PORT:8080} tomcat: + accesslog: enabled: ${IAM_TOMCAT_ACCESS_LOG_ENABLED:false} directory: ${IAM_TOMCAT_ACCESS_LOG_DIRECTORY:/tmp} diff --git a/iam-login-service/src/main/resources/email-templates/mfaDisable.ftl b/iam-login-service/src/main/resources/email-templates/mfaDisable.ftl new file mode 100644 index 000000000..7eabd5b8f --- /dev/null +++ b/iam-login-service/src/main/resources/email-templates/mfaDisable.ftl @@ -0,0 +1,15 @@ +Dear ${recipient}, + +this mail is to inform that your Multi-Factor Authentication (MFA) in ${organisationName} has been successfully disabled. +As a result, you can now delete the existing entry from your authenticator. + + +To ensure the security of your account, please follow these steps: +1. Open your authenticator. +2. Locate the entry associated with our service. +3. Delete the entry. + +If you have any questions or need further assistance, please do not hesitate to contact our support team. + + +The ${organisationName} management service diff --git a/iam-login-service/src/main/resources/email-templates/mfaEnable.ftl b/iam-login-service/src/main/resources/email-templates/mfaEnable.ftl new file mode 100644 index 000000000..9fb835af3 --- /dev/null +++ b/iam-login-service/src/main/resources/email-templates/mfaEnable.ftl @@ -0,0 +1,10 @@ +Dear ${recipient}, + +this mail is to inform that your Multi-Factor Authentication (MFA) in ${organisationName} has been successfully enabled. + +If you have any questions or need further assistance, please do not hesitate to contact our support team. + +If you encounter issues with your authenticator, please contact the Administrator to request MFA deactivation. + + +The ${organisationName} management service diff --git a/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag b/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag index 937d331b2..e6ca98319 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag +++ b/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag @@ -119,4 +119,8 @@ function getRefreshTokenValiditySeconds() { function getClientTrackLastUsed() { return ${clientTrackLastUsed}; } + +function getMfaSettingsBtnEnabled() { + return ${loginPageConfiguration.mfaSettingsBtnEnabled}; +} diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp new file mode 100644 index 000000000..8ab0068db --- /dev/null +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp @@ -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. + +--%> + +

+
+
+ For your security, please enter a TOTP from your authenticator +
+
+
+ + + + +
+
+ + +
+
+ +
+
+ + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp index a9bf2d60d..deebe2572 100644 --- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp @@ -92,6 +92,7 @@ + @@ -115,11 +116,14 @@ + - + + + @@ -136,6 +140,10 @@ + + + + + + +
+ +
+ + + +
${SPRING_SECURITY_LAST_EXCEPTION.message}
+
+
+
+ + + + +
+ +
+
+
+ \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.html index fb9a1b7c2..a8781bd65 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.html +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.html @@ -54,6 +54,15 @@

{{$ctrl.user.name.formatted}}

+ + + MFA + + + + + + Created diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js index 93a52e6bc..e32e6ea8b 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js @@ -44,6 +44,10 @@ return self.indigoUser() && self.indigoUser().endTime; }; + self.isMfaSettingsBtnEnabled = function () { + return Utils.isMfaSettingsBtnEnabled(); + }; + } angular.module('dashboardApp').component('userDetail', { diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html new file mode 100644 index 000000000..5b37667f8 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.js new file mode 100644 index 000000000..822c52413 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.js @@ -0,0 +1,62 @@ +/* + * 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. + */ +(function() { + 'use strict'; + + function DisableMfaController( + toaster, Utils, ModalService, $uibModal) { + var self = this; + + self.$onInit = function () { + console.log('DisableMfaController onInit'); + self.enabled = true; + self.user = self.userCtrl.user; + }; + + self.isMe = function() { return self.userCtrl.isMe(); }; + self.isVoAdmin = function () { return self.userCtrl.isVoAdmin(); }; + + self.openDisableMfaModal = function() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html', + controller: 'DisableMfaController', + controllerAs: 'disableMfaCtrl', + resolve: {user: function() { return self.user; }} + }); + + modalInstance.result.then(function(msg) { + self.userCtrl.loadUser().then(function () { + toaster.pop({ + type: 'success', + body: msg + }); + }); + }); + }; + } + + + + angular.module('dashboardApp').component('userDisableMfa', { + require: {userCtrl: '^user'}, + templateUrl: + '/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html', + controller: [ + 'toaster', 'Utils', 'ModalService', '$uibModal', + DisableMfaController + ] + }); +})(); \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html new file mode 100644 index 000000000..7bdff3602 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js new file mode 100644 index 000000000..8a8da5262 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js @@ -0,0 +1,63 @@ +/* + * 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. + */ +(function() { + 'use strict'; + + function EditMfaController( + toaster, Utils, ModalService, $uibModal) { + var self = this; + + self.$onInit = function() { + console.log('EditMfaController onInit'); + self.enabled = true; + self.user = self.userCtrl.user; + }; + + self.isMe = function() { return self.userCtrl.isMe(); }; + + self.isMfaActive = function() { return self.userCtrl.user.isMfaActive; }; + + self.openUserMfaModal = function() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html', + controller: 'UserMfaController', + controllerAs: 'userMfaCtrl', + resolve: {user: function() { return self.user; }} + }); + + modalInstance.result.then(function (msg) { + self.userCtrl.loadUser().then(function () { + toaster.pop({ + type: 'success', + body: msg + }); + }); + }); + }; + } + + + + angular.module('dashboardApp').component('userMfa', { + require: {userCtrl: '^user'}, + templateUrl: + '/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html', + controller: [ + 'toaster', 'Utils', 'ModalService', '$uibModal', + EditMfaController + ] + }); +})(); \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html index 4a1b32498..04f8db5d9 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html @@ -68,13 +68,18 @@

- + + + + + + diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js index 6aa8ea1fd..b3b8f2e8b 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js @@ -34,6 +34,10 @@ return Utils.isAdmin(); }; + self.isMfaSettingsBtnEnabled = function () { + return Utils.isMfaSettingsBtnEnabled(); + }; + self.isGroupManager = function () { return Utils.isGroupManager(); }; diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js new file mode 100644 index 000000000..f678ee8b2 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js @@ -0,0 +1,148 @@ +/* + * 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. + */ +(function () { + 'use strict'; + + angular.module('dashboardApp') + .controller('EnableAuthenticatorAppController', EnableAuthenticatorAppController); + + angular.module('dashboardApp') + .controller('DisableAuthenticatorAppController', DisableAuthenticatorAppController); + + EnableAuthenticatorAppController.$inject = [ + '$scope', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user', '$uibModal' + ]; + + DisableAuthenticatorAppController.$inject = [ + '$scope', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user' + ]; + + function EnableAuthenticatorAppController( + $scope, $uibModalInstance, Utils, AuthenticatorAppService, user, $uibModal) { + var authAppCtrl = this; + + authAppCtrl.user = { + ...user, + code: '' + }; + + authAppCtrl.$onInit = function () { + AuthenticatorAppService.addMfaSecretToUser().then(function (response) { + authAppCtrl.secret = response.data.secret; + authAppCtrl.dataUri = response.data.dataUri; + }); + } + + authAppCtrl.codeMinlength = 6; + authAppCtrl.requestPending = false; + + authAppCtrl.dismiss = dismiss; + authAppCtrl.reset = reset; + + function reset() { + console.log('reset form'); + + authAppCtrl.user.code = ''; + + if ($scope.authenticatorAppForm) { + $scope.authenticatorAppForm.$setPristine(); + } + + authAppCtrl.requestPending = false; + } + + authAppCtrl.reset(); + + function dismiss() { return $uibModalInstance.dismiss('Cancel'); } + + authAppCtrl.message = ''; + + authAppCtrl.clearError = function () { + $scope.operationResult = null; + }; + + authAppCtrl.submitEnable = function () { + authAppCtrl.requestPending = true; + AuthenticatorAppService + .enableAuthenticatorApp( + authAppCtrl.user.code) + .then(function () { + authAppCtrl.requestPending = false; + $uibModalInstance.close('Authenticator enabled'); + }) + .catch(function (error) { + authAppCtrl.requestPending = false; + $scope.operationResult = Utils.buildErrorResult(error.data.error); + authAppCtrl.reset(); + }); + }; + } + + function DisableAuthenticatorAppController( + $scope, $uibModalInstance, Utils, AuthenticatorAppService, user) { + var authAppCtrl = this; + + authAppCtrl.user = { + ...user, + code: '' + }; + + authAppCtrl.codeMinlength = 6; + authAppCtrl.requestPending = false; + + authAppCtrl.dismiss = dismiss; + authAppCtrl.reset = reset; + + function reset() { + console.log('reset form'); + + authAppCtrl.user.code = ''; + + if ($scope.authenticatorAppForm) { + $scope.authenticatorAppForm.$setPristine(); + } + + authAppCtrl.requestPending = false; + } + + authAppCtrl.reset(); + + function dismiss() { return $uibModalInstance.dismiss('Cancel'); } + + authAppCtrl.message = ''; + + authAppCtrl.clearError = function () { + $scope.operationResult = null; + }; + + authAppCtrl.submitDisable = function () { + authAppCtrl.requestPending = true; + AuthenticatorAppService + .disableAuthenticatorApp( + authAppCtrl.user.code) + .then(function () { + authAppCtrl.requestPending = false; + return $uibModalInstance.close('Authenticator disabled'); + }) + .catch(function (error) { + authAppCtrl.requestPending = false; + $scope.operationResult = Utils.buildErrorResult(error.data.error); + authAppCtrl.reset(); + }); + }; + } + +})(); \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/disable-mfa.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/disable-mfa.controller.js new file mode 100644 index 000000000..2fdf4af44 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/disable-mfa.controller.js @@ -0,0 +1,49 @@ +/* + * 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. + */ +'use strict'; + +angular.module('dashboardApp') + .controller('DisableMfaController', DisableMfaController); + +DisableMfaController.$inject = [ + '$http', '$scope', '$state', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user', '$uibModal', 'toaster' +]; + +function DisableMfaController( + $http, $scope, $state, $uibModalInstance, Utils, AuthenticatorAppService, user, $uibModal, toaster) { + var disableMfaCtrl = this; + + disableMfaCtrl.userToEdit = user; + + disableMfaCtrl.disableMfa = function () { + AuthenticatorAppService.disableAuthenticatorAppForUser(user.id).then(function(result) { + if (result != null && result.status === 200) { + $uibModalInstance.close('Multi-factor authentication disabled'); + } else { + var message = "Unable to disable multi-factor authentication"; + console.error(message); + $uibModalInstance.close(message); + } + }).catch(function(error) { + console.error(error); + toaster.pop({ type: 'error', body: error.data.error }); + $uibModalInstance.dismiss(); + }); + }; + + disableMfaCtrl.cancel = function () { return $uibModalInstance.close('Cancel'); }; + +} diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js new file mode 100644 index 000000000..59df73a4a --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js @@ -0,0 +1,94 @@ +/* + * 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. + */ +'use strict'; + +angular.module('dashboardApp') + .controller('UserMfaController', UserMfaController); + +UserMfaController.$inject = [ + '$http', '$scope', '$state', '$uibModalInstance', 'Utils', 'user', '$uibModal', 'toaster' +]; + +function UserMfaController( + $http, $scope, $state, $uibModalInstance, Utils, user, $uibModal, toaster) { + var userMfaCtrl = this; + + userMfaCtrl.$onInit = function() { + console.log('UserMfaController onInit'); + getMfaSettings(); + }; + + // TODO include this data in what is fetched from the /scim/me endpoint + function getMfaSettings() { + $http.get('/iam/multi-factor-settings').then(function(response) { + userMfaCtrl.authenticatorAppActive = response.data.authenticatorAppActive; + }); + } + + userMfaCtrl.userToEdit = user; + + userMfaCtrl.enableAuthenticatorApp = enableAuthenticatorApp; + userMfaCtrl.disableAuthenticatorApp = disableAuthenticatorApp; + + function enableAuthenticatorApp() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html', + controller: 'EnableAuthenticatorAppController', + controllerAs: 'authAppCtrl', + resolve: { user: function() { return self.user; } } + }); + + modalInstance.result.then(function(msg) { + return $uibModalInstance.close(msg); + }); + } + + function disableAuthenticatorApp() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html', + controller: 'DisableAuthenticatorAppController', + controllerAs: 'authAppCtrl', + resolve: { user: function() { return self.user; } } + }); + + modalInstance.result.then(function(msg) { + return $uibModalInstance.close(msg); + }); + } + + userMfaCtrl.dismiss = dismiss; + userMfaCtrl.reset = reset; + + function reset() { + console.log('reset form'); + + userMfaCtrl.enabled = true; + + if ($scope.userMfaForm) { + $scope.userMfaForm.$setPristine(); + } + } + + userMfaCtrl.reset(); + + function dismiss() { return $uibModalInstance.dismiss('Cancel'); } + + userMfaCtrl.message = ''; + + userMfaCtrl.submit = function() { + return $uibModalInstance.close('Updated settings'); + }; +} diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js new file mode 100644 index 000000000..aa204bec8 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js @@ -0,0 +1,88 @@ +/* + * 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. + */ +'use strict' + +angular.module('dashboardApp').factory('AuthenticatorAppService', AuthenticatorAppService); + +AuthenticatorAppService.$inject = ['$http', '$httpParamSerializerJQLike']; + +function AuthenticatorAppService($http, $httpParamSerializerJQLike) { + + var service = { + addMfaSecretToUser: addMfaSecretToUser, + enableAuthenticatorApp: enableAuthenticatorApp, + disableAuthenticatorApp: disableAuthenticatorApp, + disableAuthenticatorAppForUser: disableAuthenticatorAppForUser, + getMfaSettings: getMfaSettings, + getMfaSettingsForAccount: getMfaSettingsForAccount + }; + + return service; + + function addMfaSecretToUser() { + return $http.put('/iam/authenticator-app/add-secret'); + } + + function enableAuthenticatorApp(code) { + + var data = $httpParamSerializerJQLike({ + code: code + }); + + var config = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }; + + return $http.post('/iam/authenticator-app/enable', data, config); + }; + + function disableAuthenticatorApp(code) { + + var data = $httpParamSerializerJQLike({ + code: code + }); + + var config = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }; + + return $http.post('/iam/authenticator-app/disable', data, config); + }; + + function disableAuthenticatorAppForUser(userId) { + return $http.delete('/iam/authenticator-app/reset/' + userId); + } + + function handleSuccess(res) { + return res.data.authenticatorAppActive; + } + + function handleError(res) { + return $q.reject(res); + } + + function getMfaSettingsForAccount(userId) { + return $http.get('/iam/multi-factor-settings/' + userId).then(handleSuccess).catch(handleError); + } + + function getMfaSettings() { + return $http.get('/iam/multi-factor-settings/').then(handleSuccess).catch(handleError); + } +} \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js index 7e84d76ea..53da640cc 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js @@ -26,8 +26,11 @@ '/resources/iam/apps/dashboard-app/templates/common/userinfo-box.html', '/resources/iam/apps/dashboard-app/templates/header.html', '/resources/iam/apps/dashboard-app/templates/home/account-link-dialog.html', + '/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html', + '/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html', '/resources/iam/apps/dashboard-app/templates/home/editpassword.html', '/resources/iam/apps/dashboard-app/templates/home/edituser.html', + '/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html', '/resources/iam/apps/dashboard-app/templates/home/home.html', '/resources/iam/apps/dashboard-app/templates/loading-modal.html', '/resources/iam/apps/dashboard-app/templates/nav.html', diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js index 223cf2f0b..e2f794aaa 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js @@ -17,9 +17,9 @@ angular.module('dashboardApp').factory('UserService', UserService); -UserService.$inject = ['$q', '$rootScope', 'scimFactory', 'Authorities', 'Utils', 'AupService', 'UsersService', 'GroupsService']; +UserService.$inject = ['$q', '$rootScope', 'scimFactory', 'Authorities', 'Utils', 'AupService', 'UsersService', 'GroupsService', 'AuthenticatorAppService']; -function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService, UsersService, GroupsService) { +function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService, UsersService, GroupsService, AuthenticatorAppService) { var service = { getUser: getUser, getMe: getMe, @@ -38,7 +38,7 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService function getMe() { return $q.all([getMeAndAuthorities(), - AupService.getAupSignature() + AupService.getAupSignature(), AuthenticatorAppService.getMfaSettings() ]).then( function (result) { var user = result[0]; @@ -51,7 +51,9 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService } else { user.aupSignature = null; } - + if (result[2] !== null) { + user.isMfaActive = result[2]; + } return user; }).catch(function (error) { console.error('Error loading authenticated user information: ', error); @@ -61,7 +63,9 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService function getUser(userId) { return $q - .all([scimFactory.getUser(userId), Authorities.getAuthorities(userId), AupService.getAupSignatureForUser(userId)]) + .all([scimFactory.getUser(userId), Authorities.getAuthorities(userId), AupService.getAupSignatureForUser(userId), + AuthenticatorAppService.getMfaSettingsForAccount(userId) + ]) .then(function (result) { var user = result[0].data; user.authorities = result[1].data.authorities; @@ -74,6 +78,10 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService } else { user.aupSignature = null; } + + if (result[3] !== null) { + user.isMfaActive = result[3]; + } return user; }) .catch(function (error) { diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js index bbe64d850..e6a8afb24 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -(function() { +(function () { 'use strict'; @@ -29,6 +29,7 @@ isMe: isMe, isAdmin: isAdmin, isUser: isUser, + isPreAuthenticated: isPreAuthenticated, getLoggedUser: getLoggedUser, isRegistrationEnabled: isRegistrationEnabled, isOidcEnabled: isOidcEnabled, @@ -40,7 +41,8 @@ isGroupManagerForGroup: isGroupManagerForGroup, isGroupManager: isGroupManager, isGroupMember: isGroupMember, - username: username + username: username, + isMfaSettingsBtnEnabled: isMfaSettingsBtnEnabled }; return service; @@ -86,6 +88,11 @@ return (getUserAuthorities().indexOf("ROLE_USER") != -1); } + function isPreAuthenticated() { + + return (getUserAuthorities().indexOf("ROLE_PRE_AUTHENTICATED") != -1); + } + function isGroupManager() { const hasGmAuth = getUserAuthorities().filter((c) => c.startsWith('ROLE_GM:')); @@ -115,6 +122,10 @@ return getSamlEnabled(); } + function isMfaSettingsBtnEnabled() { + return getMfaSettingsBtnEnabled(); + } + function buildErrorResult(errorString) { return { diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html new file mode 100644 index 000000000..f61a54723 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html @@ -0,0 +1,50 @@ + +
+ + + + + + + +
\ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html new file mode 100644 index 000000000..0f19e12e3 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html @@ -0,0 +1,37 @@ + + + + + + + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html new file mode 100644 index 000000000..bb58c1c29 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html @@ -0,0 +1,59 @@ + +
+ + + + + + +
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html new file mode 100644 index 000000000..5ede93c52 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html @@ -0,0 +1,53 @@ + +
+ + + + + + + +
diff --git a/iam-login-service/src/main/webapp/resources/iam/css/iam.css b/iam-login-service/src/main/webapp/resources/iam/css/iam.css index 672d4fde2..b1008dee0 100644 --- a/iam-login-service/src/main/webapp/resources/iam/css/iam.css +++ b/iam-login-service/src/main/webapp/resources/iam/css/iam.css @@ -71,6 +71,12 @@ max-width: 250px; } +.verify-form { + padding-top: 1em; + margin: 0 auto; + max-width: 250px; +} + #sign-aup-form { padding-top: 2em; margin: 0 auto; @@ -106,6 +112,11 @@ max-width: 400px; } +#verify-error { + margin: 0 auto; + max-width: 400px; +} + #login-external-authn { margin: 0 auto; padding-top: 2em; @@ -156,6 +167,10 @@ margin-top: 2em; } +#verify-confirm { + margin-top: 2em +} + .reset-password-form { margin: 0 auto; max-width: 400px; @@ -336,10 +351,20 @@ body.skin-blue { color: inherit; } +.btn-verify { + background-color: white; + border-color: #ddd; + color: inherit; +} + .btn-login:hover { background-color: white; } +.btn-verify:hover { + background-color: white; +} + .login-image-size-SMALL { margin-left: 5px; height: 22px; @@ -364,6 +389,11 @@ body.skin-blue { text-align: center; } +.verify-preamble { + margin-bottom: 1em; + text-align: center; +} + .registration-preamble { margin-bottom: 1em; } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/MultiFactorSettingsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/MultiFactorSettingsTests.java new file mode 100644 index 000000000..26509dc35 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/MultiFactorSettingsTests.java @@ -0,0 +1,125 @@ +/** + * 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.api.account.multi_factor_authentication; + +import static it.infn.mw.iam.test.TestUtils.passwordTokenGetter; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.junit4.SpringRunner; + +import io.restassured.RestAssured; +import io.restassured.response.ValidatableResponse; +import it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsController; +import it.infn.mw.iam.api.scim.model.ScimEmail; +import it.infn.mw.iam.api.scim.model.ScimName; +import it.infn.mw.iam.api.scim.model.ScimUser; +import it.infn.mw.iam.api.scim.provisioning.ScimUserProvisioning; +import it.infn.mw.iam.test.TestUtils; +import it.infn.mw.iam.test.util.annotation.IamRandomPortIntegrationTest; + +@RunWith(SpringRunner.class) +@IamRandomPortIntegrationTest +public class MultiFactorSettingsTests { + + @Value("${local.server.port}") + private Integer iamPort; + + private ScimUser testUser; + + private final String USER_USERNAME = "test_user"; + private final String USER_PASSWORD = "password"; + private final ScimName USER_NAME = + ScimName.builder().givenName("TESTER").familyName("USER").build(); + private final ScimEmail USER_EMAIL = ScimEmail.builder().email("test_user@test.org").build(); + + @Autowired + private ScimUserProvisioning userService; + + @BeforeClass + public static void init() { + TestUtils.initRestAssured(); + } + + @Before + public void setup() { + testUser = userService.create(ScimUser.builder() + .active(true) + .addEmail(USER_EMAIL) + .name(USER_NAME) + .displayName(USER_USERNAME) + .userName(USER_USERNAME) + .password(USER_PASSWORD) + .build()); + } + + @After + public void tearDown() { + userService.delete(testUser.getId()); + } + + private ValidatableResponse doGet(String accessToken) { + return RestAssured.given() + .port(iamPort) + .auth() + .preemptive() + .oauth2(accessToken) + .log() + .all(true) + .when() + .get(MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_URL) + .then() + .log() + .all(true); + } + + private ValidatableResponse doGet() { + return RestAssured.given() + .port(iamPort) + .log() + .all(true) + .when() + .get(MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_URL) + .then() + .log() + .all(true); + } + + @Test + public void testGetSettings() { + String accessToken = passwordTokenGetter().port(iamPort) + .username(testUser.getUserName()) + .password(USER_PASSWORD) + .getAccessToken(); + + doGet(accessToken).statusCode(HttpStatus.OK.value()); + } + + @Test + public void testGetSettingsFullAuthenticationRequired() { + doGet().statusCode(HttpStatus.UNAUTHORIZED.value()) + .body("error", equalTo("unauthorized")) + .body("error_description", + equalTo("Full authentication is required to access this resource")); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticationAppSettingsTotpTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticationAppSettingsTotpTests.java new file mode 100644 index 000000000..bb7b18094 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticationAppSettingsTotpTests.java @@ -0,0 +1,141 @@ +/** + * 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.api.account.multi_factor_authentication.authenticator_app; + +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ADD_SECRET_URL; +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ENABLE_URL; +import static org.hamcrest.CoreMatchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrGenerator; +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.config.mfa.IamTotpMfaProperties; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.test.TestUtils; +import it.infn.mw.iam.test.multi_factor_authentication.MultiFactorTestSupport; +import it.infn.mw.iam.test.util.WithMockOAuthUser; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class AuthenticationAppSettingsTotpTests extends MultiFactorTestSupport { + + private MockMvc mvc; + + @Autowired + private WebApplicationContext context; + + @MockBean + private IamAccountRepository accountRepository; + + @MockBean + private IamTotpMfaService totpMfaService; + + @MockBean + private IamTotpMfaProperties iamTotpMfaProperties; + + @MockBean + private QrGenerator qrGenerator; + + + @BeforeClass + public static void init() { + TestUtils.initRestAssured(); + } + + @Before + public void setup() { + when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT)); + when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(KEY_TO_ENCRYPT_DECRYPT); + + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testAddSecretThrowsQrGenerationException() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(account)); + + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa); + + when(qrGenerator.generate(any(QrData.class))).thenThrow( + new QrGenerationException("Simulated QR generation failure", new RuntimeException())); + + mvc.perform(put(ADD_SECRET_URL)) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("Could not generate QR code"))); + + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).addTotpMfaSecret(account); + verify(qrGenerator, times(1)).generate(any(QrData.class)); + } + + @Test + @WithMockOAuthUser(user = TEST_USERNAME, authorities = "ROLE_USER") + public void testEnableAuthenticatorAppViaOauthAuthn() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setActive(true); + totpMfa.setAccount(account); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(true); + when(totpMfaService.enableTotpMfa(account)).thenReturn(totpMfa); + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isOk()); + + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, times(1)).enableTotpMfa(account); + } +} \ No newline at end of file diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java new file mode 100644 index 000000000..040a59ecf --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java @@ -0,0 +1,421 @@ +/** + * 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.api.account.multi_factor_authentication.authenticator_app; + +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ADD_SECRET_URL; +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.DISABLE_URL; +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ENABLE_URL; +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.MFA_SECRET_NOT_FOUND_MESSAGE; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.util.NestedServletException; + +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.config.mfa.IamTotpMfaProperties; +import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.test.TestUtils; +import it.infn.mw.iam.test.multi_factor_authentication.MultiFactorTestSupport; +import it.infn.mw.iam.test.util.WithAnonymousUser; +import it.infn.mw.iam.test.util.WithMockMfaUser; +import it.infn.mw.iam.test.util.WithMockPreAuthenticatedUser; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class AuthenticatorAppSettingsControllerTests extends MultiFactorTestSupport { + + private MockMvc mvc; + + @Autowired + private WebApplicationContext context; + + @MockBean + private IamAccountRepository accountRepository; + + @MockBean + private IamTotpMfaService totpMfaService; + + @MockBean + private IamTotpMfaProperties iamTotpMfaProperties; + + @BeforeClass + public static void init() { + TestUtils.initRestAssured(); + } + + @Before + public void setup() { + when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT)); + when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(KEY_TO_ENCRYPT_DECRYPT); + + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testAddSecret() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setActive(false); + totpMfa.setAccount(null); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa); + + mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isOk()); + + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).addTotpMfaSecret(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testAddSecretThrowsMfaSecretAlreadyBoundException() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setActive(false); + totpMfa.setAccount(null); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + when(totpMfaService.addTotpMfaSecret(account)).thenThrow(new MfaSecretAlreadyBoundException( + "A multi-factor secret is already assigned to this account")); + + mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isConflict()); + + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).addTotpMfaSecret(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testAddSecret_withEmptyPassword() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setActive(false); + totpMfa.setAccount(null); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + + when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa); + when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(""); + + NestedServletException thrownException = assertThrows(NestedServletException.class, () -> { + mvc.perform(put(ADD_SECRET_URL)); + }); + + assertTrue( + thrownException.getCause().getMessage().startsWith("Please ensure that you provide")); + } + + @Test + @WithAnonymousUser + public void testAddSecretNoAuthenticationIsUnauthorized() throws Exception { + mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testAddSecretPreAuthenticationIsUnauthorized() throws Exception { + mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorApp() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setActive(true); + totpMfa.setAccount(account); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(true); + when(totpMfaService.enableTotpMfa(account)).thenReturn(totpMfa); + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isOk()); + + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, times(1)).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppThrowsTotpMfaAlreadyEnabledException() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(true); + when(totpMfaService.enableTotpMfa(account)) + .thenThrow(new TotpMfaAlreadyEnabledException("TOTP MFA is already enabled on this account")); + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isConflict()); + + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, times(1)).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppIncorrectCode() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(false); + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppButTotpVerificationFails() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)) + .thenThrow(new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE)); + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppInvalidCharactersInCode() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "abcdef"; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppCodeTooShort() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "12345"; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppCodeTooLong() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "1234567"; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppNullCode() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = null; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithMockUser(username = TEST_USERNAME) + public void testEnableAuthenticatorAppEmptyCode() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = ""; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).enableTotpMfa(account); + } + + @Test + @WithAnonymousUser + public void testEnableAuthenticatorAppNoAuthenticationIsUnauthorized() throws Exception { + String totp = "123456"; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testEnableAuthenticatorAppPreAuthenticationIsUnauthorized() throws Exception { + String totp = "654321"; + + mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorApp() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(true); + when(totpMfaService.disableTotpMfa(account)).thenReturn(totpMfa); + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().isOk()); + + verify(accountRepository, times(2)).findByUsername(TOTP_USERNAME); + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, times(1)).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppIncorrectCode() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(false); + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppButTotpVerificationFails() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)) + .thenThrow(new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE)); + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, times(1)).verifyTotp(account, totp); + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppInvalidCharactersInCode() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = "123456"; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppCodeTooShort() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = "12345"; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppCodeTooLong() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = "1234567"; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppNullCode() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = null; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithMockMfaUser + public void testDisableAuthenticatorAppEmptyCode() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + String totp = ""; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError()); + + verify(totpMfaService, never()).disableTotpMfa(account); + } + + @Test + @WithAnonymousUser + public void testDisableAuthenticatorAppNoAuthenticationIsUnauthorized() throws Exception { + String totp = "123456"; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testDisableAuthenticatorAppPreAuthenticationIsUnauthorized() throws Exception { + String totp = "654321"; + + mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().isUnauthorized()); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java index 14ee9764d..c95bdcbec 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java @@ -53,7 +53,7 @@ public class GroupRequestsGetDetailsTests extends GroupRequestsTestUtils { public void getGroupRequestDetailsAsAdmin() throws Exception { GroupRequestDto request = savePendingGroupRequest(TEST_100_USERNAME, TEST_001_GROUPNAME); - + // @formatter:off mvc.perform(get(GET_DETAILS_URL, request.getUuid())) .andExpect(status().isOk()) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java index cb97f60e9..9d6588164 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java @@ -51,8 +51,7 @@ @IamMockMvcIntegrationTest @SpringBootTest( classes = {IamLoginService.class, CoreControllerTestSupport.class, - AccountLifecycleNoSuspensionGracePeriodTests.TestConfig.class}, - webEnvironment = WebEnvironment.MOCK) + AccountLifecycleNoSuspensionGracePeriodTests.TestConfig.class}, webEnvironment = WebEnvironment.MOCK) @TestPropertySource( properties = {"lifecycle.account.expiredAccountPolicy.suspensionGracePeriodDays=0", "lifecycle.account.expiredAccountPolicy.removalGracePeriodDays=30"}) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java index bd00353a1..489537e99 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java @@ -203,5 +203,4 @@ public void testNoAccountsRemoved() { assertThat(accountBefore, is(accountAfter)); } - } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/ExtendedAuthenticationTokenTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/ExtendedAuthenticationTokenTests.java new file mode 100644 index 000000000..341677079 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/ExtendedAuthenticationTokenTests.java @@ -0,0 +1,94 @@ +/** + * 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.multi_factor_authentication; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import it.infn.mw.iam.core.ExtendedAuthenticationToken; + +public class ExtendedAuthenticationTokenTests { + + @Test + void testEqualsSameObjects() { + ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password"); + ExtendedAuthenticationToken token2 = token1; + + assertEquals(token1, token2, "Same objects should be equal"); + } + + @Test + void testEqualsIdenticalFields() { + ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password"); + ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user1", "password"); + + assertEquals(token1, token2, "Objects with identical fields should be equal"); + } + + @Test + void testEqualsDifferentFields() { + ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password"); + ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user2", "password"); + + assertNotEquals(token1, token2, "Objects with different fields should not be equal"); + } + + @Test + void testEqualsSubclassInstance() { + ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password"); + AbstractAuthenticationToken token2 = new ExtendedAuthenticationToken("user1", "password"); + + assertEquals(token1, token2, "Subclass instances with identical fields should be equal"); + } + + @Test + void testHashCodeEqualObjects() { + ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password"); + ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user1", "password"); + + assertEquals(token1.hashCode(), token2.hashCode(), + "Equal objects must have the same hash code"); + } + + @Test + void testHashCodeDifferentObjects() { + ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password"); + ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user2", "password"); + + assertNotEquals(token1.hashCode(), token2.hashCode(), + "Unequal objects should not have the same hash code"); + } + + @Test + void testEqualsWithAuthorities() { + Set authorities = new HashSet<>(); + authorities.add(() -> "ROLE_USER"); + + ExtendedAuthenticationToken token1 = + new ExtendedAuthenticationToken("user1", "password", authorities); + ExtendedAuthenticationToken token2 = + new ExtendedAuthenticationToken("user1", "password", authorities); + + assertEquals(token1, token2, "Objects with identical authorities should be equal"); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpAuthenticationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpAuthenticationTests.java new file mode 100644 index 000000000..079ad4bc3 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpAuthenticationTests.java @@ -0,0 +1,162 @@ +/** + * 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.multi_factor_authentication; + +import static org.hamcrest.CoreMatchers.is; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.junit4.SpringRunner; + +import io.restassured.RestAssured; +import io.restassured.response.ValidatableResponse; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +import it.infn.mw.iam.test.TestUtils; +import it.infn.mw.iam.test.util.annotation.IamRandomPortIntegrationTest; + +@RunWith(SpringRunner.class) +@IamRandomPortIntegrationTest +public class IamTotpAuthenticationTests { + + @Autowired + IamTotpMfaRepository totpMfaRepo; + + @Value("${local.server.port}") + private Integer iamPort; + + public static final String TEST_CLIENT_ID = "client"; + public static final String TEST_CLIENT_REDIRECT_URI = + "https://iam.local.io/iam-test-client/openid_connect_login"; + + public static final String LOCALHOST_URL_TEMPLATE = "http://localhost:%d"; + + public static final String RESPONSE_TYPE_CODE = "code"; + + public static final String SCOPE = + "openid profile scim:read scim:write offline_access iam:admin.read iam:admin.write"; + + private String loginUrl; + private String authorizeUrl; + private String verifyUrl; + + @BeforeClass + public static void init() { + TestUtils.initRestAssured(); + + } + + @Before + public void setup() { + RestAssured.port = iamPort; + loginUrl = String.format(LOCALHOST_URL_TEMPLATE + "/login", iamPort); + authorizeUrl = String.format(LOCALHOST_URL_TEMPLATE + "/authorize", iamPort); + verifyUrl = String.format(LOCALHOST_URL_TEMPLATE + "/iam/verify", iamPort); + } + + @Test + public void testRedirectToVerifyPageAfterLogin() { + + // @formatter:off + ValidatableResponse resp1 = RestAssured.given() + .queryParam("response_type", RESPONSE_TYPE_CODE) + .queryParam("client_id", TEST_CLIENT_ID) + .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) + .queryParam("scope", SCOPE) + .queryParam("nonce", "1") + .queryParam("state", "1") + .redirects().follow(false) + .when() + .get(authorizeUrl) + .then() + .statusCode(HttpStatus.FOUND.value()) + .header("Location", is(loginUrl)); + // @formatter:on + + // @formatter:off + RestAssured.given() + .formParam("username", "test-with-mfa") + .formParam("password", "password") + .formParam("submit", "Login") + .cookie(resp1.extract().detailedCookie("JSESSIONID")) + .redirects().follow(false) + .when() + .post(loginUrl) + .then() + .statusCode(HttpStatus.FOUND.value()) + .header("Location", is(verifyUrl)); + // @formatter:on + } + + @Test + public void testRedirectToAuthorizeUrlWhenTotpIsInactive() { + + IamTotpMfa totp = totpMfaRepo.findByAccountId(Long.valueOf(1000)).orElseThrow(); + totp.setActive(false); + totpMfaRepo.save(totp); + + // @formatter:off + ValidatableResponse resp1 = RestAssured.given() + .queryParam("response_type", RESPONSE_TYPE_CODE) + .queryParam("client_id", TEST_CLIENT_ID) + .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) + .queryParam("scope", SCOPE) + .queryParam("nonce", "1") + .queryParam("state", "1") + .redirects().follow(false) + .when() + .get(authorizeUrl) + .then() + .statusCode(HttpStatus.FOUND.value()) + .header("Location", is(loginUrl)); + // @formatter:on + + // @formatter:off + RestAssured.given() + .formParam("username", "test-with-mfa") + .formParam("password", "password") + .formParam("submit", "Login") + .cookie(resp1.extract().detailedCookie("JSESSIONID")) + .redirects().follow(false) + .when() + .post(loginUrl) + .then() + .statusCode(HttpStatus.FOUND.value()); + // @formatter:on + + // @formatter:off + RestAssured.given() + .cookie(resp1.extract().detailedCookie("JSESSIONID")) + .queryParam("response_type", RESPONSE_TYPE_CODE) + .queryParam("client_id", TEST_CLIENT_ID) + .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) + .queryParam("scope", SCOPE) + .queryParam("nonce", "1") + .queryParam("state", "1") + .redirects().follow(false) + .when() + .get(authorizeUrl) + .then() + .log().all() + .statusCode(HttpStatus.OK.value()); + // @formatter:on + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaCommons.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaCommons.java new file mode 100644 index 000000000..f27cc6d9b --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaCommons.java @@ -0,0 +1,31 @@ +/** + * 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.multi_factor_authentication; + +public class IamTotpMfaCommons { + public static final String KEY_TO_ENCRYPT_DECRYPT = "define_me_please"; + public static final String TOTP_MFA_SECRET = "secret"; + + public static final int DEFAULT_KEY_SIZE = 128; + public static final int DEFAULT_ITERATIONS = 65536; + public static final int DEFAULT_SALT_SIZE = 16; + + public static final int ANOTHER_KEY_SIZE = 192; + public static final int ANOTHER_ITERATIONS = 6000; + public static final int ANOTHER_SALT_SIZE = 24; + + public static final int INVALID_SALT_SIZE = 0; +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaEncryptionAndDecryptionUtilTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaEncryptionAndDecryptionUtilTests.java new file mode 100644 index 000000000..4f1ed62ea --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaEncryptionAndDecryptionUtilTests.java @@ -0,0 +1,170 @@ +/** + * 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.multi_factor_authentication; + +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionHelper; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; +import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError; + +@RunWith(MockitoJUnitRunner.class) +public class IamTotpMfaEncryptionAndDecryptionUtilTests extends IamTotpMfaCommons { + + private static final IamTotpMfaEncryptionAndDecryptionHelper defaultModel = IamTotpMfaEncryptionAndDecryptionHelper + .getInstance(); + + @Before + public void setUp() { + defaultModel.setIterations(ANOTHER_ITERATIONS); + defaultModel.setKeyLengthInBits(ANOTHER_KEY_SIZE); + defaultModel.setSaltLengthInBytes(ANOTHER_SALT_SIZE); + } + + @After + public void tearDown() { + defaultModel.setIterations(DEFAULT_ITERATIONS); + defaultModel.setKeyLengthInBits(DEFAULT_KEY_SIZE); + defaultModel.setSaltLengthInBytes(DEFAULT_SALT_SIZE); + } + + @Test + public void testEncryptionAndDecryptionSecretMethods() throws IamTotpMfaInvalidArgumentError { + // Encrypt the plainText + String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + KEY_TO_ENCRYPT_DECRYPT); + + // Decrypt the cipherText + String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText, + KEY_TO_ENCRYPT_DECRYPT); + + assertEquals(TOTP_MFA_SECRET, plainText); + } + + @Test + public void testDecryptSecretWithDifferentKey() throws IamTotpMfaInvalidArgumentError { + // Encrypt the plainText + String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + KEY_TO_ENCRYPT_DECRYPT); + + IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + // Decrypt the cipherText with a different key + IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText, "NOT_THE_SAME_KEY"); + }); + + assertTrue(thrownException.getMessage().startsWith("An error occurred while decrypting")); + + // Decrypt the cipherText with a the same key used for encryption. + String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText, + KEY_TO_ENCRYPT_DECRYPT); + + assertEquals(TOTP_MFA_SECRET, plainText); + } + + @Test + public void testEncryptSecretWithTamperedCipher() throws IamTotpMfaInvalidArgumentError { + // Encrypt the plainText + String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + KEY_TO_ENCRYPT_DECRYPT); + + String modifyCipher = cipherText.substring(1); + String tamperedCipher = "i" + modifyCipher; + + if (!tamperedCipher.substring(0, 3).equals(cipherText.substring(0, 3))) { + + IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + // Decrypt the tampered cipherText + IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(tamperedCipher, KEY_TO_ENCRYPT_DECRYPT); + }); + + // Always throws an error because user have tampered the cipherText. + assertTrue(thrownException.getMessage().contains("An error occurred while decrypting")); + } else { + + // Decrypt the right cipherText with a the same key used for encryption. + String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText, + KEY_TO_ENCRYPT_DECRYPT); + + assertEquals(TOTP_MFA_SECRET, plainText); + } + } + + @Test + public void testEncryptSecretWithEmptyPlainText() throws IamTotpMfaInvalidArgumentError { + + IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + // Try to encrypt the empty plainText + IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(null, KEY_TO_ENCRYPT_DECRYPT); + }); + + // Always throws an error because we have passed empty plaintext. + assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide")); + } + + @Test + public void testEncryptSecretWithInvalidSaltSize() throws IamTotpMfaInvalidArgumentError { + defaultModel.setSaltLengthInBytes(INVALID_SALT_SIZE); + + IamTotpMfaInvalidArgumentError throwException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT); + }); + + assertTrue(throwException.getCause().getMessage().startsWith("the salt parameter must not")); + } + + @Test + public void testDecryptSecretWithEmptyPlainText() throws IamTotpMfaInvalidArgumentError { + + IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(null, KEY_TO_ENCRYPT_DECRYPT); + }); + + // Always throws an error because we have passed empty ciphertext. + assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide")); + } + + @Test + public void testEncryptSecretWithAesCipher_CBC_Mode() throws IamTotpMfaInvalidArgumentError { + defaultModel.setShortFormOfCipherMode( + IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.CBC); + defaultModel.setModeOfOperation( + IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.CBC.getCipherMode()); + + // Encrypt the plainText with CBC Cipher mode + String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, + KEY_TO_ENCRYPT_DECRYPT); + + // Decrypt the cipherText with CBC Cipher mode + String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText, + KEY_TO_ENCRYPT_DECRYPT); + + defaultModel.setShortFormOfCipherMode( + IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.GCM); + defaultModel.setModeOfOperation( + IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.GCM.getCipherMode()); + + // Expect encryption and decryption works as expected in CBC Cipher mode. + assertEquals(TOTP_MFA_SECRET, plainText); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java new file mode 100644 index 000000000..92fdfa4dc --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java @@ -0,0 +1,85 @@ +/** + * 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.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamAuthority; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; + +public class IamTotpMfaServiceTestSupport extends IamTotpMfaCommons { + + public static final String PASSWORD = "password"; + + public static final String TOTP_MFA_ACCOUNT_UUID = "b3e7dd7f-a1ac-eda0-371d-b902a6c5cee2"; + public static final String TOTP_MFA_ACCOUNT_USERNAME = "totp"; + public static final String TOTP_MFA_ACCOUNT_EMAIL = "totp@example.org"; + public static final String TOTP_MFA_ACCOUNT_GIVEN_NAME = "Totp"; + public static final String TOTP_MFA_ACCOUNT_FAMILY_NAME = "Mfa"; + + public static final String TOTP_CODE = "123456"; + + protected final IamAccount TOTP_MFA_ACCOUNT; + protected final IamAuthority ROLE_USER_AUTHORITY; + + protected final IamTotpMfa TOTP_MFA; + + public IamTotpMfaServiceTestSupport() { + ROLE_USER_AUTHORITY = new IamAuthority("ROLE_USER"); + + TOTP_MFA_ACCOUNT = IamAccount.newAccount(); + TOTP_MFA_ACCOUNT.setUuid(TOTP_MFA_ACCOUNT_UUID); + TOTP_MFA_ACCOUNT.setUsername(TOTP_MFA_ACCOUNT_USERNAME); + TOTP_MFA_ACCOUNT.getUserInfo().setEmail(TOTP_MFA_ACCOUNT_EMAIL); + TOTP_MFA_ACCOUNT.getUserInfo().setGivenName(TOTP_MFA_ACCOUNT_GIVEN_NAME); + TOTP_MFA_ACCOUNT.getUserInfo().setFamilyName(TOTP_MFA_ACCOUNT_FAMILY_NAME); + + TOTP_MFA = new IamTotpMfa(); + TOTP_MFA.setAccount(TOTP_MFA_ACCOUNT); + TOTP_MFA.setSecret(getEncryptedCode(TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT)); + TOTP_MFA.setActive(true); + + TOTP_MFA.touch(); + } + + public IamAccount cloneAccount(IamAccount account) { + IamAccount newAccount = IamAccount.newAccount(); + newAccount.setUuid(account.getUuid()); + newAccount.setUsername(account.getUsername()); + newAccount.getUserInfo().setEmail(account.getUserInfo().getEmail()); + newAccount.getUserInfo().setGivenName(account.getUserInfo().getGivenName()); + newAccount.getUserInfo().setFamilyName(account.getUserInfo().getFamilyName()); + + newAccount.touch(); + + return newAccount; + } + + public IamTotpMfa cloneTotpMfa(IamTotpMfa totpMfa) { + IamTotpMfa newTotpMfa = new IamTotpMfa(); + newTotpMfa.setAccount(totpMfa.getAccount()); + newTotpMfa.setSecret(totpMfa.getSecret()); + newTotpMfa.setActive(totpMfa.isActive()); + + newTotpMfa.touch(); + + return newTotpMfa; + } + + public String getEncryptedCode(String plaintext, String key) { + return IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(plaintext, key); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java new file mode 100644 index 000000000..7f18fde5e --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java @@ -0,0 +1,291 @@ +/** + * 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.multi_factor_authentication; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; + +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.secret.SecretGenerator; +import it.infn.mw.iam.api.account.multi_factor_authentication.DefaultIamTotpMfaService; +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppDisabledEvent; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppEnabledEvent; +import it.infn.mw.iam.config.mfa.IamTotpMfaProperties; +import it.infn.mw.iam.core.user.IamAccountService; +import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; +import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError; + +@RunWith(MockitoJUnitRunner.class) +public class IamTotpMfaServiceTests extends IamTotpMfaServiceTestSupport { + + private IamTotpMfaService service; + + @Mock + private IamTotpMfaRepository repository; + + @Mock + private SecretGenerator secretGenerator; + + @Mock + private IamAccountService iamAccountService; + + @Mock + private CodeVerifier codeVerifier; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private IamTotpMfaProperties iamTotpMfaProperties; + + @Captor + private ArgumentCaptor eventCaptor; + + @Before + public void setup() { + when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(KEY_TO_ENCRYPT_DECRYPT); + + when(secretGenerator.generate()).thenReturn("test_secret"); + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA)); + when(iamAccountService.saveAccount(TOTP_MFA_ACCOUNT)).thenAnswer(i -> i.getArguments()[0]); + when(codeVerifier.isValidCode(anyString(), anyString())).thenReturn(true); + + service = new DefaultIamTotpMfaService(iamAccountService, repository, secretGenerator, + codeVerifier, eventPublisher, iamTotpMfaProperties); + } + + @After + public void tearDown() { + reset(secretGenerator, repository, iamAccountService, codeVerifier); + } + + @Test + public void testAssignsTotpMfaToAccount() { + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty()); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + IamTotpMfa totpMfa = service.addTotpMfaSecret(account); + verify(repository, times(1)).save(totpMfa); + verify(secretGenerator, times(1)).generate(); + + assertNotNull(totpMfa.getSecret()); + assertFalse(totpMfa.isActive()); + assertThat(totpMfa.getAccount(), equalTo(account)); + } + + @Test(expected = MfaSecretAlreadyBoundException.class) + public void testAddMfaSecret_whenMfaSecretAssignedFails() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + try { + service.addTotpMfaSecret(account); + } catch (MfaSecretAlreadyBoundException e) { + assertThat(e.getMessage(), + equalTo("A multi-factor secret is already assigned to this account")); + throw e; + } + } + + @Test + public void testAddMfaSecretWhenTotpIsNotActive() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + TOTP_MFA.setActive(false); + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA)); + IamTotpMfa totpMfa = service.addTotpMfaSecret(account); + assertFalse(totpMfa.isActive()); + } + + @Test + public void testAddTotpMfaSecret_whenPasswordIsEmpty() { + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty()); + when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(""); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + IamTotpMfaInvalidArgumentError thrownException = + assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + // Decrypt the cipherText with empty key + service.addTotpMfaSecret(account); + }); + + assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide")); + } + + @Test + public void testEnablesTotpMfa() throws Exception { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret("secret", + iamTotpMfaProperties.getPasswordToEncryptOrDecrypt())); + totpMfa.setActive(false); + totpMfa.setAccount(account); + + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa)); + + service.enableTotpMfa(account); + verify(repository, times(1)).save(totpMfa); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + + ApplicationEvent event = eventCaptor.getValue(); + assertThat(event, instanceOf(AuthenticatorAppEnabledEvent.class)); + + AuthenticatorAppEnabledEvent e = (AuthenticatorAppEnabledEvent) event; + assertTrue(e.getTotpMfa().isActive()); + assertThat(e.getTotpMfa().getAccount(), equalTo(account)); + } + + @Test(expected = TotpMfaAlreadyEnabledException.class) + public void testEnableTotpMfa_whenTotpMfaEnabledFails() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + try { + service.enableTotpMfa(account); + } catch (TotpMfaAlreadyEnabledException e) { + assertThat(e.getMessage(), equalTo("TOTP MFA is already enabled on this account")); + throw e; + } + } + + @Test(expected = MfaSecretNotFoundException.class) + public void testEnablesTotpMfa_whenNoMfaSecretAssignedFails() { + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty()); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + try { + service.enableTotpMfa(account); + } catch (MfaSecretNotFoundException e) { + assertThat(e.getMessage(), equalTo("No multi-factor secret is attached to this account")); + throw e; + } + } + + @Test + public void testDisablesTotpMfa() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + + service.disableTotpMfa(account); + verify(repository, times(1)).delete(totpMfa); + verify(iamAccountService, times(1)).saveAccount(account); + verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture()); + + ApplicationEvent event = eventCaptor.getValue(); + assertThat(event, instanceOf(AuthenticatorAppDisabledEvent.class)); + + AuthenticatorAppDisabledEvent e = (AuthenticatorAppDisabledEvent) event; + assertThat(e.getTotpMfa().getAccount(), equalTo(account)); + } + + @Test(expected = MfaSecretNotFoundException.class) + public void testDisablesTotpMfa_whenNoMfaSecretAssignedFails() { + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty()); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + try { + service.disableTotpMfa(account); + } catch (MfaSecretNotFoundException e) { + assertThat(e.getMessage(), equalTo("No multi-factor secret is attached to this account")); + throw e; + } + } + + @Test + public void testVerifyTotp_WithNoMultiFactorSecretAttached() { + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty()); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + MfaSecretNotFoundException thrownException = + assertThrows(MfaSecretNotFoundException.class, () -> { + service.verifyTotp(account, TOTP_CODE); + }); + + assertTrue(thrownException.getMessage().startsWith("No multi-factor secret is attached")); + } + + @Test + public void testVerifyTotp() { + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa)); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + assertTrue(service.verifyTotp(account, TOTP_CODE)); + } + + @Test + public void testVerifyTotp_WithEmptyPasswordForDecryption() { + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa)); + when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(""); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + IamTotpMfaInvalidArgumentError thrownException = + assertThrows(IamTotpMfaInvalidArgumentError.class, () -> { + service.verifyTotp(account, TOTP_CODE); + }); + + assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide")); + } + + @Test + public void testVerifyTotp_WithCodeNotValid() { + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa)); + when(codeVerifier.isValidCode(anyString(), anyString())).thenReturn(false); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + assertFalse(service.verifyTotp(account, TOTP_CODE)); + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.java new file mode 100644 index 000000000..db190f751 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.java @@ -0,0 +1,111 @@ +/** + * 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.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import it.infn.mw.iam.api.common.NoSuchAccountError; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class MfaVerifyControllerTests extends MultiFactorTestSupport { + + private MockMvc mvc; + + @Autowired + private WebApplicationContext context; + + @MockBean + private IamAccountRepository accountRepository; + + @MockBean + private IamTotpMfaRepository totpMfaRepository; + + @Before + public void setup() { + when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT)); + when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @Test + @WithMockUser(username = "test-mfa-user", authorities = {"ROLE_PRE_AUTHENTICATED"}) + public void testGetVerifyMfaView() throws Exception { + mvc.perform(get(MFA_VERIFY_URL)) + .andExpect(status().isOk()) + .andExpect(model().attributeExists("factors")); + + verify(totpMfaRepository, times(1)).findByAccount(TOTP_MFA_ACCOUNT); + } + + @Test + @WithMockUser(username = "test-mfa-user", authorities = {"ROLE_PRE_AUTHENTICATED"}) + public void testGetVerifyMfaViewWhenTotpAlreadyPresent() throws Exception { + when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA)); + mvc.perform(get(MFA_VERIFY_URL)) + .andExpect(status().isOk()) + .andExpect(model().attributeExists("factors")); + + verify(totpMfaRepository, times(1)).findByAccount(TOTP_MFA_ACCOUNT); + } + + @Test + @WithMockUser(username = "test-mfa-user", authorities = {"ROLE_PRE_AUTHENTICATED"}) + public void testGetVerifyMfaViewThrowsNoSuchAccountError() throws Exception { + when(accountRepository.findByUsername(TOTP_USERNAME)) + .thenThrow(new NoSuchAccountError(String.format("Account not found for username '%s'", TOTP_USERNAME))); + mvc.perform(get(MFA_VERIFY_URL)).andExpect(status().isBadRequest()); + + verify(totpMfaRepository, times(0)).findByAccount(TOTP_MFA_ACCOUNT); + } + + @Test + public void testGetMfaVerifyViewNoAuthenticationIsUnauthorized() throws Exception { + mvc.perform(get(MFA_VERIFY_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + public void testGetMfaVerifyViewWithFullAuthenticationIsForbidden() throws Exception { + mvc.perform(get(MFA_VERIFY_URL)).andExpect(status().isForbidden()); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorSettingsControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorSettingsControllerTests.java new file mode 100644 index 000000000..7ab83a4ea --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorSettingsControllerTests.java @@ -0,0 +1,88 @@ +/** + * 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.multi_factor_authentication; + +import static it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL; +import static it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_URL; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +import it.infn.mw.iam.test.util.WithAnonymousUser; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class MultiFactorSettingsControllerTests extends MultiFactorTestSupport { + private MockMvc mvc; + @Autowired + private WebApplicationContext context; + @MockBean + private IamAccountRepository accountRepository; + @MockBean + private IamTotpMfaRepository totpMfaRepository; + + @Before + public void setup() { + when(accountRepository.findByUuid(TOTP_UUID)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA)); + + mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @Test + @WithAnonymousUser + public void testGetMfaAccountSettingNoAuthenticationFails() throws Exception { + mvc.perform(get(MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL, TOTP_UUID)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + public void testGetMfaAccountSettingWorksForAdmin() throws Exception { + mvc.perform(get(MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL, TOTP_UUID)) + .andExpect(status().isOk()) + .andExpect((jsonPath("$.authenticatorAppActive", equalTo(true)))); + } + + @Test + @WithMockUser(username = "test-mfa-user", roles = "USER") + public void testGetMfaAccountSettingWorksForAuthenticatedUser() throws Exception { + mvc.perform(get(MULTI_FACTOR_SETTINGS_URL)) + .andExpect(status().isOk()) + .andExpect((jsonPath("$.authenticatorAppActive", equalTo(true)))); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java new file mode 100644 index 000000000..f97302253 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java @@ -0,0 +1,120 @@ +/** + * 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.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil; + +public class MultiFactorTestSupport extends IamTotpMfaCommons{ + public static final String TEST_USERNAME = "test-user"; + public static final String TEST_UUID = "a23deabf-88a7-47af-84b5-1d535a1b267c"; + public static final String TEST_EMAIL = "test@example.org"; + public static final String TEST_GIVEN_NAME = "Test"; + public static final String TEST_FAMILY_NAME = "User"; + public static final String TOTP_USERNAME = "test-mfa-user"; + public static final String TOTP_UUID = "ceb173b4-28e3-43ad-aaf7-15d3730e2b90"; + public static final String TOTP_EMAIL = "test-mfa@example.org"; + public static final String TOTP_GIVEN_NAME = "Test"; + public static final String TOTP_FAMILY_NAME = "Mfa"; + + protected final IamAccount TEST_ACCOUNT; + protected final IamAccount TOTP_MFA_ACCOUNT; + protected final IamTotpMfa TOTP_MFA; + + public MultiFactorTestSupport() { + TEST_ACCOUNT = IamAccount.newAccount(); + TEST_ACCOUNT.setUsername(TEST_USERNAME); + TEST_ACCOUNT.setUuid(TEST_UUID); + TEST_ACCOUNT.getUserInfo().setEmail(TEST_EMAIL); + TEST_ACCOUNT.getUserInfo().setGivenName(TEST_GIVEN_NAME); + TEST_ACCOUNT.getUserInfo().setFamilyName(TEST_FAMILY_NAME); + + TEST_ACCOUNT.touch(); + + TOTP_MFA_ACCOUNT = IamAccount.newAccount(); + TOTP_MFA_ACCOUNT.setUsername(TOTP_USERNAME); + TOTP_MFA_ACCOUNT.setUuid(TOTP_UUID); + TOTP_MFA_ACCOUNT.getUserInfo().setEmail(TOTP_EMAIL); + TOTP_MFA_ACCOUNT.getUserInfo().setGivenName(TOTP_GIVEN_NAME); + TOTP_MFA_ACCOUNT.getUserInfo().setFamilyName(TOTP_FAMILY_NAME); + + TOTP_MFA_ACCOUNT.touch(); + + TOTP_MFA = new IamTotpMfa(); + TOTP_MFA.setAccount(TOTP_MFA_ACCOUNT); + TOTP_MFA.setSecret( + IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret( + TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT)); + TOTP_MFA.setActive(true); + TOTP_MFA.touch(); + } + + protected void resetTestAccount() { + TEST_ACCOUNT.setUsername(TEST_USERNAME); + TEST_ACCOUNT.setUuid(TEST_UUID); + TEST_ACCOUNT.getUserInfo().setEmail(TEST_EMAIL); + TEST_ACCOUNT.getUserInfo().setGivenName(TEST_GIVEN_NAME); + TEST_ACCOUNT.getUserInfo().setFamilyName(TEST_FAMILY_NAME); + + TEST_ACCOUNT.touch(); + } + + protected void resetTotpAccount() { + TOTP_MFA_ACCOUNT.setUsername(TOTP_USERNAME); + TOTP_MFA_ACCOUNT.setUuid(TOTP_UUID); + TOTP_MFA_ACCOUNT.getUserInfo().setEmail(TOTP_EMAIL); + TOTP_MFA_ACCOUNT.getUserInfo().setGivenName(TOTP_GIVEN_NAME); + TOTP_MFA_ACCOUNT.getUserInfo().setFamilyName(TOTP_FAMILY_NAME); + + TOTP_MFA_ACCOUNT.touch(); + + TOTP_MFA.setAccount(TOTP_MFA_ACCOUNT); + TOTP_MFA.setSecret( + IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret( + TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT)); + TOTP_MFA.setActive(true); + TOTP_MFA.touch(); + } + + protected IamAccount cloneAccount(IamAccount account) { + IamAccount newAccount = IamAccount.newAccount(); + newAccount.setUuid(account.getUuid()); + newAccount.setUsername(account.getUsername()); + newAccount.getUserInfo().setEmail(account.getUserInfo().getEmail()); + newAccount.getUserInfo().setGivenName(account.getUserInfo().getGivenName()); + newAccount.getUserInfo().setFamilyName(account.getUserInfo().getFamilyName()); + + newAccount.touch(); + + return newAccount; + } + + protected IamTotpMfa cloneTotpMfa(IamTotpMfa totpMfa) { + IamTotpMfa newTotpMfa = new IamTotpMfa(); + newTotpMfa.setAccount(totpMfa.getAccount()); + newTotpMfa.setSecret(totpMfa.getSecret()); + newTotpMfa.setActive(totpMfa.isActive()); + + newTotpMfa.touch(); + + return newTotpMfa; + } + + public String getEncryptedCode(String plaintext, String key) { + return IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(plaintext, key); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTotpCheckProviderTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTotpCheckProviderTests.java new file mode 100644 index 000000000..ea9e6aa80 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTotpCheckProviderTests.java @@ -0,0 +1,109 @@ +/** + * 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.multi_factor_authentication; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.authentication.BadCredentialsException; + +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorTotpCheckProvider; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; + +public class MultiFactorTotpCheckProviderTests extends IamTotpMfaServiceTestSupport { + + private MultiFactorTotpCheckProvider multiFactorTotpCheckProvider; + + @Mock + private IamAccountRepository accountRepo; + + @Mock + private IamTotpMfaService totpMfaService; + + @Mock + private ExtendedAuthenticationToken token; + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + multiFactorTotpCheckProvider = new MultiFactorTotpCheckProvider(accountRepo, totpMfaService); + } + + @Test + public void authenticateReturnsNullWhenTotpIsNull() { + when(token.getTotp()).thenReturn(null); + assertNull(multiFactorTotpCheckProvider.authenticate(token)); + } + + @Test + public void authenticateThrowsBadCredentialsExceptionWhenAccountNotFound() { + when(token.getTotp()).thenReturn("123456"); + when(token.getName()).thenReturn("username"); + when(accountRepo.findByUsername("username")).thenReturn(Optional.empty()); + + assertThrows(BadCredentialsException.class, + () -> multiFactorTotpCheckProvider.authenticate(token)); + } + + @Test + public void authenticatePropagatesMfaSecretNotFoundException() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + when(token.getName()).thenReturn("totp"); + when(token.getTotp()).thenReturn("123456"); + when(accountRepo.findByUsername("totp")).thenReturn(Optional.of(account)); + when(totpMfaService.verifyTotp(account, "123456")) + .thenThrow(new MfaSecretNotFoundException("Mfa secret not found")); + + assertThrows(MfaSecretNotFoundException.class, + () -> multiFactorTotpCheckProvider.authenticate(token)); + } + + @Test + public void authenticateThrowsBadCredentialsExceptionWhenTotpIsInvalid() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + when(token.getName()).thenReturn("totp"); + when(token.getTotp()).thenReturn("123456"); + when(accountRepo.findByUsername(anyString())).thenReturn(Optional.of(account)); + when(totpMfaService.verifyTotp(account, "123456")).thenReturn(false); + + assertThrows(BadCredentialsException.class, + () -> multiFactorTotpCheckProvider.authenticate(token)); + } + + @Test + public void authenticateReturnsSuccessfulAuthenticationWhenTotpIsValid() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + when(token.getName()).thenReturn("totp"); + when(token.getTotp()).thenReturn("123456"); + when(accountRepo.findByUsername("totp")).thenReturn(Optional.of(account)); + when(totpMfaService.verifyTotp(account, "123456")).thenReturn(true); + + assertNotNull(multiFactorTotpCheckProvider.authenticate(token)); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorVerificationFilterTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorVerificationFilterTests.java new file mode 100644 index 000000000..6abfe0b95 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorVerificationFilterTests.java @@ -0,0 +1,170 @@ +/** + * 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.multi_factor_authentication; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.ProviderNotFoundException; +import java.util.ArrayList; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; + +public class MultiFactorVerificationFilterTests { + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private AuthenticationSuccessHandler successHandler; + + @Mock + private AuthenticationFailureHandler failureHandler; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private Authentication authentication; + + @InjectMocks + private MultiFactorVerificationFilter multiFactorVerificationFilter; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + public void testAuthenticationSuccess() throws Exception { + Authentication mockAuth = mock(ExtendedAuthenticationToken.class); + when(mockAuth.getName()).thenReturn("username"); + + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + Authentication mockAuthenticatedToken = + new ExtendedAuthenticationToken("username", null, new ArrayList<>()); + when(authenticationManager.authenticate(any(Authentication.class))) + .thenReturn(mockAuthenticatedToken); + + when(request.getMethod()).thenReturn("POST"); + when(request.getParameter("totp")).thenReturn("123456"); + + Authentication result = multiFactorVerificationFilter.attemptAuthentication(request, response); + + assertNotNull(result); + assertEquals(mockAuthenticatedToken, result); + } + + @Test + public void testAuthenticationFailureDueToUnsupportedAuthnMethod() throws Exception { + Authentication mockAuth = mock(ExtendedAuthenticationToken.class); + when(mockAuth.getName()).thenReturn("username"); + + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + Authentication mockAuthenticatedToken = + new ExtendedAuthenticationToken("username", null, new ArrayList<>()); + when(authenticationManager.authenticate(any(Authentication.class))) + .thenReturn(mockAuthenticatedToken); + + when(request.getMethod()).thenReturn("GET"); + + assertThrows(AuthenticationServiceException.class, + () -> multiFactorVerificationFilter.attemptAuthentication(request, response)); + } + + @Test + public void testAuthenticationFailureDueToBadAuthn() throws Exception { + Authentication mockAuth = mock(UsernamePasswordAuthenticationToken.class); + when(mockAuth.getName()).thenReturn("username"); + + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + Authentication mockAuthenticatedToken = + new UsernamePasswordAuthenticationToken("username", null, new ArrayList<>()); + when(authenticationManager.authenticate(any(Authentication.class))) + .thenReturn(mockAuthenticatedToken); + + when(request.getMethod()).thenReturn("POST"); + + assertThrows(AuthenticationServiceException.class, + () -> multiFactorVerificationFilter.attemptAuthentication(request, response)); + } + + @Test + public void testAuthenticationFailureDueToInvalidTOTP() throws Exception { + Authentication mockAuth = mock(ExtendedAuthenticationToken.class); + when(mockAuth.getName()).thenReturn("username"); + + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + when(authenticationManager.authenticate(any(Authentication.class))) + .thenThrow(new BadCredentialsException("Invalid TOTP")); + + when(request.getMethod()).thenReturn("POST"); + when(request.getParameter("totp")).thenReturn("wrong-totp"); + + assertThrows(BadCredentialsException.class, + () -> multiFactorVerificationFilter.attemptAuthentication(request, response)); + } + + @Test + public void testAuthenticationFailureWhenTotpIsNull() { + Authentication mockAuth = mock(ExtendedAuthenticationToken.class); + when(mockAuth.getName()).thenReturn("username"); + + SecurityContextHolder.getContext().setAuthentication(mockAuth); + + when(request.getMethod()).thenReturn("POST"); + when(request.getParameter("totp")).thenReturn(null); + + assertThrows(ProviderNotFoundException.class, + () -> multiFactorVerificationFilter.attemptAuthentication(request, response)); + } +} + diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java new file mode 100644 index 000000000..ad7cd667c --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java @@ -0,0 +1,125 @@ +/** + * 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.multi_factor_authentication.authenticator_app; + +import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.DISABLE_URL_FOR_ACCOUNT_ID; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.hamcrest.Matchers.equalTo; + +import java.util.List; +import java.util.Optional; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.core.IamNotificationType; +import it.infn.mw.iam.persistence.model.IamEmailNotification; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamEmailNotificationRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +import it.infn.mw.iam.test.core.CoreControllerTestSupport; +import it.infn.mw.iam.test.multi_factor_authentication.MultiFactorTestSupport; +import it.infn.mw.iam.test.notification.NotificationTestConfig; +import it.infn.mw.iam.test.util.WithAnonymousUser; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; +import it.infn.mw.iam.test.util.notification.MockNotificationDelivery; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = { IamLoginService.class, CoreControllerTestSupport.class, + NotificationTestConfig.class }, webEnvironment = WebEnvironment.MOCK) +@IamMockMvcIntegrationTest +@TestPropertySource(properties = { "notification.disable=false" }) +public class AuthenticatorAppSettingsControllerTests extends MultiFactorTestSupport { + private MockMvc mvc; + @Autowired + private WebApplicationContext context; + @Autowired + private MockNotificationDelivery notificationDelivery; + @Autowired + private IamEmailNotificationRepository notificationRepo; + @MockBean + private IamAccountRepository accountRepository; + @MockBean + private IamTotpMfaRepository totpMfaRepository; + + @Before + public void setup() { + when(accountRepository.findByUuid(TOTP_UUID)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA)); + + mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @After + public void tearDown() { + notificationDelivery.clearDeliveredNotifications(); + } + + @Test + @WithAnonymousUser + public void testDisableAuthenticatorAppNoAuthenticationFails() throws Exception { + mvc.perform(delete(DISABLE_URL_FOR_ACCOUNT_ID, TOTP_UUID)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + public void testDisableAuthenticatorAppWorksForAdmin() throws Exception { + mvc.perform(delete(DISABLE_URL_FOR_ACCOUNT_ID, TOTP_UUID)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + public void testConfirmationEmailSentOnMfaDisable() throws Exception { + mvc.perform(delete(DISABLE_URL_FOR_ACCOUNT_ID, TOTP_UUID)) + .andExpect(status().isOk()); + + List notifications = notificationRepo + .findByNotificationType(IamNotificationType.MFA_DISABLE); + + assertEquals(1, notifications.size()); + assertEquals("[indigo-dc IAM] Multi-factor authentication (MFA) disabled", notifications.get(0).getSubject()); + + notificationDelivery.sendPendingNotifications(); + + assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(1)); + IamEmailNotification message = notificationDelivery.getDeliveredNotifications().get(0); + assertThat(message.getSubject(), equalTo("[indigo-dc IAM] Multi-factor authentication (MFA) disabled")); + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/ClientRegistrationAuthzTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/ClientRegistrationAuthzTests.java new file mode 100644 index 000000000..d61424947 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/ClientRegistrationAuthzTests.java @@ -0,0 +1,113 @@ +/** + * 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.oauth; + +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import it.infn.mw.iam.api.common.client.RegisteredClientDTO; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.client.IamAccountClientRepository; +import it.infn.mw.iam.persistence.repository.client.IamClientRepository; +import it.infn.mw.iam.test.oauth.client_registration.ClientRegistrationTestSupport; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +@TestPropertySource(properties = {"client-registration.allow-for=REGISTERED_USERS"}) +public class ClientRegistrationAuthzTests extends ClientRegistrationTestSupport { + + @Autowired + private MockMvc mvc; + + @Autowired + private IamAccountClientRepository accountClientRepo; + + @Autowired + private IamClientRepository clientRepo; + + @Autowired + private ObjectMapper mapper; + + @Autowired + private IamAccountRepository accountRepo; + + @Test + public void testClientRegistrationRequiresAuthenticatedUser() throws Exception { + + String jsonInString = ClientJsonStringBuilder.builder().scopes("test").build(); + + mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString)) + .andExpect(status().isForbidden()); + } + + @WithMockUser(username = "test", roles = "USER") + @Test + public void testClientRegistrationWorksForAuthenticatedUser() throws Exception { + + IamAccount testAccount = accountRepo.findByUsername("test").orElseThrow(); + + String jsonInString = ClientJsonStringBuilder.builder().scopes("test").build(); + + String responseJson = + mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + RegisteredClientDTO response = mapper.readValue(responseJson, RegisteredClientDTO.class); + + ClientDetailsEntity client = clientRepo.findByClientId(response.getClientId()).orElseThrow(); + accountClientRepo.findByAccountAndClient(testAccount, client).orElseThrow(); + } + + @WithMockUser(username = "admin", roles = {"ADMIN", "USER"}) + @Test + public void testClientRegistrationWorksForAdminUser() throws Exception { + + IamAccount adminAccount = accountRepo.findByUsername("admin").orElseThrow(); + + String jsonInString = ClientJsonStringBuilder.builder().scopes("test").build(); + + String responseJson = + mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + + RegisteredClientDTO response = mapper.readValue(responseJson, RegisteredClientDTO.class); + + ClientDetailsEntity client = clientRepo.findByClientId(response.getClientId()).orElseThrow(); + accountClientRepo.findByAccountAndClient(adminAccount, client).orElseThrow(); + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java index fa4905b16..eafebb951 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java @@ -112,7 +112,7 @@ public void testAuthzCodeAudienceSupport() // @formatter:on // @formatter:off - ValidatableResponse resp2 = RestAssured.given() + RestAssured.given() .formParam("username", "test") .formParam("password", "password") .formParam("submit", "Login") @@ -126,7 +126,7 @@ public void testAuthzCodeAudienceSupport() // @formatter:off RestAssured.given() - .cookie(resp2.extract().detailedCookie("JSESSIONID")) + .cookie(resp1.extract().detailedCookie("JSESSIONID")) .queryParam("response_type", RESPONSE_TYPE_CODE) .queryParam("client_id", TEST_CLIENT_ID) .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) @@ -143,8 +143,8 @@ public void testAuthzCodeAudienceSupport() // @formatter:on // @formatter:off - ValidatableResponse resp4 = RestAssured.given() - .cookie(resp2.extract().detailedCookie("JSESSIONID")) + ValidatableResponse resp2 = RestAssured.given() + .cookie(resp1.extract().detailedCookie("JSESSIONID")) .formParam("user_oauth_approval", "true") .formParam("authorize", "Authorize") .formParam("scope_openid", "openid") @@ -157,14 +157,14 @@ public void testAuthzCodeAudienceSupport() .statusCode(HttpStatus.SEE_OTHER.value()); // @formatter:on - String authzCode = UriComponentsBuilder.fromHttpUrl(resp4.extract().header("Location")) + String authzCode = UriComponentsBuilder.fromHttpUrl(resp2.extract().header("Location")) .build() .getQueryParams() .get("code") .get(0); // @formatter:off - ValidatableResponse resp5= RestAssured.given() + ValidatableResponse resp3 = RestAssured.given() .formParam("grant_type", "authorization_code") .formParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) .formParam("code", authzCode) @@ -179,9 +179,9 @@ public void testAuthzCodeAudienceSupport() // @formatter:on String accessToken = - mapper.readTree(resp5.extract().body().asString()).get("access_token").asText(); + mapper.readTree(resp3.extract().body().asString()).get("access_token").asText(); - String idToken = mapper.readTree(resp5.extract().body().asString()).get("id_token").asText(); + String idToken = mapper.readTree(resp3.extract().body().asString()).get("id_token").asText(); JWT atJwt = JWTParser.parse(accessToken); JWT itJwt = JWTParser.parse(idToken); @@ -216,7 +216,7 @@ public void testRefreshTokenAfterAuthzCodeWorks() // @formatter:on // @formatter:off - ValidatableResponse resp2 = RestAssured.given() + RestAssured.given() .formParam("username", "test") .formParam("password", "password") .formParam("submit", "Login") @@ -230,7 +230,7 @@ public void testRefreshTokenAfterAuthzCodeWorks() // @formatter:off RestAssured.given() - .cookie(resp2.extract().detailedCookie("JSESSIONID")) + .cookie(resp1.extract().detailedCookie("JSESSIONID")) .queryParam("response_type", RESPONSE_TYPE_CODE) .queryParam("client_id", TEST_CLIENT_ID) .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) @@ -246,8 +246,8 @@ public void testRefreshTokenAfterAuthzCodeWorks() // @formatter:on // @formatter:off - ValidatableResponse resp4 = RestAssured.given() - .cookie(resp2.extract().detailedCookie("JSESSIONID")) + ValidatableResponse resp2 = RestAssured.given() + .cookie(resp1.extract().detailedCookie("JSESSIONID")) .formParam("user_oauth_approval", "true") .formParam("authorize", "Authorize") .formParam("scope_openid", "openid") @@ -265,14 +265,14 @@ public void testRefreshTokenAfterAuthzCodeWorks() .statusCode(HttpStatus.SEE_OTHER.value()); // @formatter:on - String authzCode = UriComponentsBuilder.fromHttpUrl(resp4.extract().header("Location")) + String authzCode = UriComponentsBuilder.fromHttpUrl(resp2.extract().header("Location")) .build() .getQueryParams() .get("code") .get(0); // @formatter:off - ValidatableResponse resp5= RestAssured.given() + ValidatableResponse resp3 = RestAssured.given() .formParam("grant_type", "authorization_code") .formParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) .formParam("code", authzCode) @@ -287,10 +287,10 @@ public void testRefreshTokenAfterAuthzCodeWorks() // @formatter:on String refreshToken = - mapper.readTree(resp5.extract().body().asString()).get("refresh_token").asText(); + mapper.readTree(resp3.extract().body().asString()).get("refresh_token").asText(); // @formatter:off - ValidatableResponse resp6= RestAssured.given() + ValidatableResponse resp4 = RestAssured.given() .formParam("grant_type", "refresh_token") .formParam("refresh_token", refreshToken) .formParam("scope", "openid") @@ -304,7 +304,7 @@ public void testRefreshTokenAfterAuthzCodeWorks() // @formatter:on String refreshedToken = - mapper.readTree(resp6.extract().body().asString()).get("access_token").asText(); + mapper.readTree(resp4.extract().body().asString()).get("access_token").asText(); // @formatter:off RestAssured.given() diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java index e41ae79d0..1f5ca5190 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java @@ -63,7 +63,6 @@ import it.infn.mw.iam.test.oauth.client_registration.ClientRegistrationTestSupport.ClientJsonStringBuilder; import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; - @RunWith(SpringRunner.class) @IamMockMvcIntegrationTest @SpringBootTest(classes = {IamLoginService.class}, webEnvironment = WebEnvironment.MOCK) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java index d665b6f01..94aaeddb6 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java @@ -55,4 +55,5 @@ public void jwkEndpointReturnsKeyMaterial() throws Exception { // @formatter:on } + } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java index cc17a7806..57999bceb 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java @@ -86,7 +86,6 @@ public void testGroupUrnEncode() { g.setName("test"); groupService.createGroup(g); - when(userInfo.getGroups()).thenReturn(Sets.newHashSet(g)); Set urns = helper.resolveGroups(userInfo); diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java index ce96f33a2..a9dde62d8 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java @@ -136,7 +136,7 @@ public void testConsentPageReturnsFilteredScopes() { // @formatter:on // @formatter:off - ValidatableResponse loginResponse = RestAssured.given() + RestAssured.given() .cookie(authzResponse.extract().detailedCookie(SESSION)) .formParam("username", "test") .formParam("password", "password") @@ -151,7 +151,7 @@ public void testConsentPageReturnsFilteredScopes() { // @formatter:off String responseBody = RestAssured.given() - .cookie(loginResponse.extract().detailedCookie(SESSION)) + .cookie(authzResponse.extract().detailedCookie(SESSION)) .queryParam("response_type", RESPONSE_TYPE_CODE) .queryParam("client_id", TEST_CLIENT_ID) .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) @@ -194,7 +194,7 @@ public void testConsentPageDoesNotReturnAdminScopeToRegularUser() { // @formatter:on // @formatter:off - ValidatableResponse loginResponse = RestAssured.given() + RestAssured.given() .cookie(authzResponse.extract().detailedCookie(SESSION)) .formParam("username", "test") .formParam("password", "password") @@ -209,7 +209,7 @@ public void testConsentPageDoesNotReturnAdminScopeToRegularUser() { // @formatter:off String responseBody = RestAssured.given() - .cookie(loginResponse.extract().detailedCookie(SESSION)) + .cookie(authzResponse.extract().detailedCookie(SESSION)) .queryParam("response_type", RESPONSE_TYPE_CODE) .queryParam("client_id", "admin-client-rw") .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) @@ -249,7 +249,7 @@ public void testConsentPageReturnsAdminScopeToAdmins() { // @formatter:on // @formatter:off - ValidatableResponse loginResponse = RestAssured.given() + RestAssured.given() .cookie(authzResponse.extract().detailedCookie(SESSION)) .formParam("username", "admin") .formParam("password", "password") @@ -264,7 +264,7 @@ public void testConsentPageReturnsAdminScopeToAdmins() { // @formatter:off String responseBody = RestAssured.given() - .cookie(loginResponse.extract().detailedCookie(SESSION)) + .cookie(authzResponse.extract().detailedCookie(SESSION)) .queryParam("response_type", RESPONSE_TYPE_CODE) .queryParam("client_id", TEST_CLIENT_ID) .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java index dd6882b24..b84f9a495 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java @@ -164,7 +164,12 @@ public void authzCodeFlowScopeFilteringByAccountWorks() throws Exception { .getRequest() .getSession(); - session = (MockHttpSession) mvc.perform(get("/authorize").session(session)) + mvc.perform(get("/authorize").session(session) + .param("scope", "openid profile read-tasks") + .param("response_type", "code") + .param("client_id", clientId) + .param("redirect_uri", "https://iam.local.io/iam-test-client/openid_connect_login") + .param("state", "1234567")) .andExpect(status().isOk()) .andExpect(forwardedUrl("/oauth/confirm_access")) .andExpect(model().attribute("scope", equalTo("openid profile"))) @@ -207,7 +212,12 @@ public void matchingPolicyFilteringWorks() throws Exception { .getRequest() .getSession(); - session = (MockHttpSession) mvc.perform(get("/authorize").session(session)) + mvc.perform(get("/authorize").session(session) + .param("scope", "openid profile read:/ read:/that/thing write:/") + .param("response_type", "code") + .param("client_id", clientId) + .param("redirect_uri", "https://iam.local.io/iam-test-client/openid_connect_login") + .param("state", "1234567")) .andExpect(status().isOk()) .andExpect(forwardedUrl("/oauth/confirm_access")) .andExpect(model().attribute("scope", equalTo("openid profile"))) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java index 49d89e32d..14b3c028e 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java @@ -18,7 +18,11 @@ import static it.infn.mw.iam.core.IamRegistrationRequestStatus.APPROVED; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @@ -49,6 +53,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.config.IamProperties.RegistrationFieldProperties; import it.infn.mw.iam.persistence.model.IamAccount; import it.infn.mw.iam.persistence.model.IamAup; import it.infn.mw.iam.persistence.repository.IamAccountRepository; @@ -273,5 +278,25 @@ private void confirmRegistrationRequest(String confirmationKey) throws Exception .andExpect(model().attributeExists("verificationSuccess")); } + @Test + public void testRegistrationFieldReadOnlyGetterAndSetter() { + RegistrationFieldProperties properties = new RegistrationFieldProperties(); + + assertFalse(properties.isReadOnly()); + + properties.setReadOnly(true); + assertTrue(properties.isReadOnly()); + } + + @Test + public void testRegistrationFieldExternalAuthAttributeGetterAndSetter() { + RegistrationFieldProperties properties = new RegistrationFieldProperties(); + + assertNull(properties.getExternalAuthAttribute()); + + String testValue = "TestAttribute"; + properties.setExternalAuthAttribute(testValue); + assertEquals(testValue, properties.getExternalAuthAttribute()); + } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java index ef2a24dfd..c3df85dbd 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java @@ -56,6 +56,8 @@ public ResultActions postUser(ScimUser user, HttpStatus expectedStatus) throws E return doPost(getUsersLocation(), user, SCIM_CONTENT_TYPE, expectedStatus); } + + public ScimUser getUser(String uuid) throws Exception { return mapper.readValue(getUser(uuid, OK).andReturn().getResponse().getContentAsString(), diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java index 7255dcb89..f414813c6 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java @@ -17,8 +17,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import org.junit.Before; @@ -73,7 +73,6 @@ public class ScimUserServiceTests { final String TESTUSER_LABEL_NAME = "label-name"; final String TESTUSER_LABEL_VALUE = "label-value"; final String PRODUCTION_GROUP_UUID = "c617d586-54e6-411d-8e38-64967798fa8a"; - final String TESTUSER_USERNAME = "testProvisioningUser"; final String TESTUSER_PASSWORD = "password"; final ScimName TESTUSER_NAME = ScimName.builder().givenName("John").familyName("Lennon").build(); diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java index 9ff9b6115..dbc08a072 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java @@ -45,6 +45,7 @@ import it.infn.mw.iam.test.util.WithMockOAuthUser; import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + @RunWith(SpringRunner.class) @IamMockMvcIntegrationTest @SpringBootTest( @@ -105,7 +106,7 @@ private void patchMultipleWorks() throws Exception { private void patchPasswordNotSupported() throws Exception { String oldPassword = accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getPassword(); ScimUser updates = ScimUser.builder().password("newpassword").build(); @@ -113,7 +114,7 @@ private void patchPasswordNotSupported() throws Exception { scimUtils.patchMe(add, updates, BAD_REQUEST); String newPassword = accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getPassword(); assertThat(oldPassword, equalTo(newPassword)); @@ -122,7 +123,7 @@ private void patchPasswordNotSupported() throws Exception { private void patchAddOidcIdNotSupported() throws Exception { assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getOidcIds() .isEmpty(), equalTo(true)); @@ -132,7 +133,7 @@ private void patchAddOidcIdNotSupported() throws Exception { scimUtils.patchMe(add, updates, BAD_REQUEST); assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getOidcIds() .isEmpty(), equalTo(true)); } @@ -140,7 +141,7 @@ private void patchAddOidcIdNotSupported() throws Exception { private void patchAddSamlIdNotSupported() throws Exception { assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getSamlIds() .isEmpty(), equalTo(true)); @@ -151,7 +152,7 @@ private void patchAddSamlIdNotSupported() throws Exception { scimUtils.patchMe(add, updates, BAD_REQUEST); assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getSamlIds() .isEmpty(), equalTo(true)); } @@ -159,7 +160,7 @@ private void patchAddSamlIdNotSupported() throws Exception { private void patchAddX509CertificateNotSupported() throws Exception { assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getX509Certificates() .isEmpty(), equalTo(true)); @@ -173,7 +174,7 @@ private void patchAddX509CertificateNotSupported() throws Exception { scimUtils.patchMe(add, updates, BAD_REQUEST); assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getX509Certificates() .isEmpty(), equalTo(true)); } @@ -181,7 +182,7 @@ private void patchAddX509CertificateNotSupported() throws Exception { private void patchAddSshKeyIsSupported() throws Exception { assertThat(accountRepository.findByUsername(TEST_USERNAME) - .orElseThrow(() -> new IllegalStateException()) + .orElseThrow(IllegalStateException::new) .getSshKeys() .isEmpty(), equalTo(true)); @@ -327,5 +328,6 @@ public void testPatchAddX509CertificateNotSupported() throws Exception { public void testPatchAddX509CertificateNotSupportedNoToken() throws Exception { patchAddX509CertificateNotSupported(); + } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java index 2768c0ae6..67354e44d 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java @@ -339,6 +339,5 @@ public void testEmailIsNotAlreadyLinkedOnUpdate() throws Exception { .andExpect(jsonPath("$.detail", containsString("email user1@test.org already assigned to another user"))); - } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java index d300fc399..15977d202 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java @@ -15,10 +15,10 @@ */ package it.infn.mw.iam.test.scim.user.x509; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.MatcherAssert.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java index fb30a6a07..1c50ce8b8 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java @@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.Optional; import org.junit.Before; @@ -36,6 +37,8 @@ import org.springframework.security.oauth2.provider.OAuth2Authentication; import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.authn.util.Authorities; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; import it.infn.mw.iam.persistence.model.IamAccount; import it.infn.mw.iam.persistence.repository.IamAccountRepository; @@ -51,7 +54,7 @@ public class AccountUtilsTests { @Mock IamAccount account; - + @InjectMocks AccountUtils utils; @@ -59,7 +62,7 @@ public class AccountUtilsTests { public void setup() { SecurityContextHolder.clearContext(); } - + @Test public void isAuthenticatedReturnsFalseForAnonymousAuthenticationToken() { @@ -69,72 +72,108 @@ public void isAuthenticatedReturnsFalseForAnonymousAuthenticationToken() { assertThat(utils.isAuthenticated(), is(false)); } - + @Test public void isAuthenticatedReturnsFalseForNullAuthentication() { SecurityContextHolder.createEmptyContext(); assertThat(utils.isAuthenticated(), is(false)); } - + @Test public void isAuthenticatedReturnsTrueForUsernamePasswordAuthenticationToken() { - UsernamePasswordAuthenticationToken token = Mockito.mock(UsernamePasswordAuthenticationToken.class); + UsernamePasswordAuthenticationToken token = + Mockito.mock(UsernamePasswordAuthenticationToken.class); when(securityContext.getAuthentication()).thenReturn(token); SecurityContextHolder.setContext(securityContext); assertThat(utils.isAuthenticated(), is(true)); } - + + @Test + public void isAuthenticatedReturnsFalseForExtendedAuthenticationToken() { + ExtendedAuthenticationToken token = Mockito.mock(ExtendedAuthenticationToken.class); + + when(securityContext.getAuthentication()).thenReturn(token); + SecurityContextHolder.setContext(securityContext); + assertThat(utils.isAuthenticated(), is(false)); + } + + @Test + public void isPreAuthenticatedReturnsFalseForNullAuthentication() { + SecurityContextHolder.createEmptyContext(); + assertThat(utils.isPreAuthenticated(null), is(false)); + } + + @Test + public void isPreAuthenticatedReturnsFalseForEmptyAuthorities() { + UsernamePasswordAuthenticationToken token = + Mockito.mock(UsernamePasswordAuthenticationToken.class); + + assertThat(utils.isPreAuthenticated(token), is(false)); + } + + @Test + public void isPreAuthenticatedReturnsTrueForProperAuthority() { + UsernamePasswordAuthenticationToken token = + Mockito.mock(UsernamePasswordAuthenticationToken.class); + + when(token.getAuthorities()) + .thenReturn(Collections.singleton(Authorities.ROLE_PRE_AUTHENTICATED)); + assertThat(utils.isPreAuthenticated(token), is(true)); + } + @Test public void getAuthenticatedUserAccountReturnsEmptyOptionalForNullSecurityContext() { - assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false)); + assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false)); } - + @Test public void getAuthenticatedUserAccountReturnsEmptyOptionalForAnonymousSecurityContext() { AnonymousAuthenticationToken anonymousToken = Mockito.mock(AnonymousAuthenticationToken.class); when(securityContext.getAuthentication()).thenReturn(anonymousToken); SecurityContextHolder.setContext(securityContext); - assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false)); + assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false)); } - + @Test public void getAuthenticatedUserAccountWorksForUsernamePasswordAuthenticationToken() { when(account.getUsername()).thenReturn("test"); when(repo.findByUsername("test")).thenReturn(Optional.of(account)); - - UsernamePasswordAuthenticationToken token = Mockito.mock(UsernamePasswordAuthenticationToken.class); + + UsernamePasswordAuthenticationToken token = + Mockito.mock(UsernamePasswordAuthenticationToken.class); when(token.getName()).thenReturn("test"); when(securityContext.getAuthentication()).thenReturn(token); SecurityContextHolder.setContext(securityContext); - + Optional authUserAccount = utils.getAuthenticatedUserAccount(); assertThat(authUserAccount.isPresent(), is(true)); assertThat(authUserAccount.get().getUsername(), equalTo("test")); - + } - + @Test public void getAuthenticatedUserAccountWorksForOauthToken() { when(account.getUsername()).thenReturn("test"); when(repo.findByUsername("test")).thenReturn(Optional.of(account)); - - UsernamePasswordAuthenticationToken token = Mockito.mock(UsernamePasswordAuthenticationToken.class); + + UsernamePasswordAuthenticationToken token = + Mockito.mock(UsernamePasswordAuthenticationToken.class); when(token.getName()).thenReturn("test"); - + OAuth2Authentication oauth = Mockito.mock(OAuth2Authentication.class); when(oauth.getUserAuthentication()).thenReturn(token); - + when(securityContext.getAuthentication()).thenReturn(oauth); SecurityContextHolder.setContext(securityContext); - + Optional authUserAccount = utils.getAuthenticatedUserAccount(); assertThat(authUserAccount.isPresent(), is(true)); assertThat(authUserAccount.get().getUsername(), equalTo("test")); - + } - + @Test public void getAuthenticatedUserAccountReturnsEmptyOptionalForClientOAuthToken() { OAuth2Authentication oauth = Mockito.mock(OAuth2Authentication.class); @@ -142,7 +181,7 @@ public void getAuthenticatedUserAccountReturnsEmptyOptionalForClientOAuthToken() when(oauth.getUserAuthentication()).thenReturn(null); when(securityContext.getAuthentication()).thenReturn(oauth); SecurityContextHolder.setContext(securityContext); - - assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false)); + + assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false)); } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java index b8221a4c4..585100abc 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java @@ -44,27 +44,27 @@ public class IamAccountServiceTestSupport { public static final String TEST_OIDC_ID_ISSUER = "oidcIssuer"; public static final String TEST_OIDC_ID_SUBJECT = "oidcSubject"; - + public static final String TEST_SSH_KEY_VALUE_1 = "ssh-key-value-1"; public static final String TEST_SSH_KEY_VALUE_2 = "ssh-key-value-2"; - + public static final String TEST_X509_CERTIFICATE_VALUE_1 = "x509-cert-value-1"; public static final String TEST_X509_CERTIFICATE_SUBJECT_1 = "x509-cert-subject-1"; public static final String TEST_X509_CERTIFICATE_ISSUER_1 = "x509-cert-issuer-1"; public static final String TEST_X509_CERTIFICATE_LABEL_1 = "x509-cert-label-1"; - + public static final String TEST_X509_CERTIFICATE_VALUE_2 = "x509-cert-value-2"; public static final String TEST_X509_CERTIFICATE_SUBJECT_2 = "x509-cert-subject-2"; public static final String TEST_X509_CERTIFICATE_ISSUER_2 = "x509-cert-issuer-2"; public static final String TEST_X509_CERTIFICATE_LABEL_2 = "x509-cert-label-2"; - - + + protected final IamAccount TEST_ACCOUNT; protected final IamAccount CICCIO_ACCOUNT; protected final IamAuthority ROLE_USER_AUTHORITY; protected final IamSamlId TEST_SAML_ID; protected final IamOidcId TEST_OIDC_ID; - + protected final IamSshKey TEST_SSH_KEY_1; protected final IamSshKey TEST_SSH_KEY_2; protected final IamX509Certificate TEST_X509_CERTIFICATE_1; @@ -77,7 +77,7 @@ public IamAccountServiceTestSupport() { TEST_ACCOUNT.getUserInfo().setEmail(TEST_EMAIL); TEST_ACCOUNT.getUserInfo().setGivenName(TEST_GIVEN_NAME); TEST_ACCOUNT.getUserInfo().setFamilyName(TEST_FAMILY_NAME); - + ROLE_USER_AUTHORITY = new IamAuthority("ROLE_USER"); CICCIO_ACCOUNT = IamAccount.newAccount(); @@ -89,27 +89,22 @@ public IamAccountServiceTestSupport() { TEST_SAML_ID = new IamSamlId(TEST_SAML_ID_IDP_ID, TEST_SAML_ID_ATTRIBUTE_ID, TEST_SAML_ID_USER_ID); - - TEST_OIDC_ID = - new IamOidcId(TEST_OIDC_ID_ISSUER, TEST_OIDC_ID_SUBJECT); - - TEST_SSH_KEY_1 = - new IamSshKey(TEST_SSH_KEY_VALUE_1); - - TEST_SSH_KEY_2 = - new IamSshKey(TEST_SSH_KEY_VALUE_2); - - TEST_X509_CERTIFICATE_1 = - new IamX509Certificate(); - + + TEST_OIDC_ID = new IamOidcId(TEST_OIDC_ID_ISSUER, TEST_OIDC_ID_SUBJECT); + + TEST_SSH_KEY_1 = new IamSshKey(TEST_SSH_KEY_VALUE_1); + + TEST_SSH_KEY_2 = new IamSshKey(TEST_SSH_KEY_VALUE_2); + + TEST_X509_CERTIFICATE_1 = new IamX509Certificate(); + TEST_X509_CERTIFICATE_1.setLabel(TEST_X509_CERTIFICATE_LABEL_1); TEST_X509_CERTIFICATE_1.setSubjectDn(TEST_X509_CERTIFICATE_SUBJECT_1); TEST_X509_CERTIFICATE_1.setIssuerDn(TEST_X509_CERTIFICATE_ISSUER_1); TEST_X509_CERTIFICATE_1.setCertificate(TEST_X509_CERTIFICATE_VALUE_1); - - TEST_X509_CERTIFICATE_2 = - new IamX509Certificate(); - + + TEST_X509_CERTIFICATE_2 = new IamX509Certificate(); + TEST_X509_CERTIFICATE_2.setLabel(TEST_X509_CERTIFICATE_LABEL_2); TEST_X509_CERTIFICATE_2.setSubjectDn(TEST_X509_CERTIFICATE_SUBJECT_2); TEST_X509_CERTIFICATE_2.setIssuerDn(TEST_X509_CERTIFICATE_ISSUER_2); @@ -124,8 +119,8 @@ public IamAccount cloneAccount(IamAccount account) { newAccount.getUserInfo().setGivenName(account.getUserInfo().getGivenName()); newAccount.getUserInfo().setFamilyName(account.getUserInfo().getFamilyName()); + newAccount.touch(); + return newAccount; } - - } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java index f044c1fe6..c882279d4 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java @@ -42,6 +42,8 @@ import java.util.List; import java.util.Optional; +import com.google.common.collect.Sets; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -57,8 +59,6 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; -import com.google.common.collect.Sets; - import it.infn.mw.iam.audit.events.account.AccountEndTimeUpdatedEvent; import it.infn.mw.iam.audit.events.account.EmailReplacedEvent; import it.infn.mw.iam.audit.events.account.FamilyNameReplacedEvent; @@ -148,7 +148,6 @@ public void setup() { when(accountRepo.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT)); when(accountRepo.findByEmail(TEST_EMAIL)).thenReturn(Optional.of(TEST_ACCOUNT)); when(accountRepo.findByEmailWithDifferentUUID(TEST_EMAIL, CICCIO_UUID)).thenThrow(EmailAlreadyBoundException.class); - when(authoritiesRepo.findByAuthority(anyString())).thenReturn(Optional.empty()); when(authoritiesRepo.findByAuthority("ROLE_USER")).thenReturn(Optional.of(ROLE_USER_AUTHORITY)); when(passwordEncoder.encode(any())).thenReturn(PASSWORD); @@ -1056,5 +1055,4 @@ public void testNoDefaultGroupsAddedWhenDefaultGroupsNotGiven() { Optional groupMembershipOptional = account.getGroups().stream().findFirst(); assertFalse(groupMembershipOptional.isPresent()); } - } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java index 0a09ab384..dbeae03e9 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java @@ -27,6 +27,12 @@ public static Authentication adminAuthentication() { } public static Authentication userAuthentication() { - return new UsernamePasswordAuthenticationToken("test", "", AuthorityUtils.createAuthorityList("ROLE_USER")); + return new UsernamePasswordAuthenticationToken("test", "", + AuthorityUtils.createAuthorityList("ROLE_USER")); + } + + public static Authentication preAuthenticatedAuthentication() { + return new UsernamePasswordAuthenticationToken("test_pre_authenticated", "", + AuthorityUtils.createAuthorityList("ROLE_PRE_AUTHENTICATED")); } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockMfaUser.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockMfaUser.java new file mode 100644 index 000000000..556544041 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockMfaUser.java @@ -0,0 +1,32 @@ +/** + * 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.util; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import it.infn.mw.iam.test.util.multi_factor_authentication.WithMockMfaUserSecurityContextFactory; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockMfaUserSecurityContextFactory.class) +public @interface WithMockMfaUser { + + String username() default "test-mfa-user"; + + String[] authorities() default {"ROLE_USER"}; +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockPreAuthenticatedUser.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockPreAuthenticatedUser.java new file mode 100644 index 000000000..910a5baff --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockPreAuthenticatedUser.java @@ -0,0 +1,32 @@ +/** + * 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.util; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import it.infn.mw.iam.test.util.multi_factor_authentication.WithMockPreAuthenticatedUserSecurityContextFactory; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockPreAuthenticatedUserSecurityContextFactory.class) +public @interface WithMockPreAuthenticatedUser { + + String username() default "test-mfa-user"; + + String[] authorities() default {"ROLE_PRE_AUTHENTICATED"}; +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java index 989082579..e8e00c074 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java @@ -37,6 +37,7 @@ classes = {IamLoginService.class, CoreControllerTestSupport.class, ScimRestUtilsMvc.class}, webEnvironment = WebEnvironment.MOCK) @AutoConfigureMockMvc(printOnlyOnFailure = true, print = MockMvcPrint.LOG_DEBUG) + @Transactional public @interface IamMockMvcIntegrationTest { diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockMfaUserSecurityContextFactory.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockMfaUserSecurityContextFactory.java new file mode 100644 index 000000000..36f1a6c4c --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockMfaUserSecurityContextFactory.java @@ -0,0 +1,55 @@ +/** + * 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.util.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.PASSWORD; +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.ONE_TIME_PASSWORD; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; +import it.infn.mw.iam.test.util.WithMockMfaUser; + +public class WithMockMfaUserSecurityContextFactory + implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockMfaUser annotation) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + IamAuthenticationMethodReference pwd = + new IamAuthenticationMethodReference(PASSWORD.getValue()); + IamAuthenticationMethodReference otp = + new IamAuthenticationMethodReference(ONE_TIME_PASSWORD.getValue()); + Set refs = + new HashSet(Arrays.asList(pwd, otp)); + + ExtendedAuthenticationToken token = new ExtendedAuthenticationToken(annotation.username(), "", + AuthorityUtils.createAuthorityList(annotation.authorities())); + token.setAuthenticated(true); + token.setAuthenticationMethodReferences(refs); + context.setAuthentication(token); + return context; + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockPreAuthenticatedUserSecurityContextFactory.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockPreAuthenticatedUserSecurityContextFactory.java new file mode 100644 index 000000000..5a11f8f27 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockPreAuthenticatedUserSecurityContextFactory.java @@ -0,0 +1,52 @@ +/** + * 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.util.multi_factor_authentication; + +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.PASSWORD; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; +import it.infn.mw.iam.test.util.WithMockPreAuthenticatedUser; + +public class WithMockPreAuthenticatedUserSecurityContextFactory + implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockPreAuthenticatedUser annotation) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + IamAuthenticationMethodReference pwd = + new IamAuthenticationMethodReference(PASSWORD.getValue()); + Set refs = + new HashSet(Arrays.asList(pwd)); + + ExtendedAuthenticationToken token = new ExtendedAuthenticationToken(annotation.username(), "", + AuthorityUtils.createAuthorityList(annotation.authorities())); + token.setAuthenticated(false); + token.setAuthenticationMethodReferences(refs); + context.setAuthentication(token); + return context; + } +} diff --git a/iam-persistence/pom.xml b/iam-persistence/pom.xml index 81928ca94..ac8ddf060 100644 --- a/iam-persistence/pom.xml +++ b/iam-persistence/pom.xml @@ -44,6 +44,11 @@ spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-validation + + org.springframework.boot spring-boot-starter-data-jpa @@ -108,6 +113,11 @@ jaxb-runtime + + com.fasterxml.jackson.datatype + jackson-datatype-joda + + diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java b/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java index ba3c26c74..774d5c380 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java @@ -16,5 +16,6 @@ package it.infn.mw.iam.core; public enum IamNotificationType { - CONFIRMATION, RESETPASSWD, ACTIVATED, REJECTED, GROUP_MEMBERSHIP, AUP_REMINDER, AUP_EXPIRATION, AUP_SIGNATURE_REQUEST, ACCOUNT_SUSPENDED, ACCOUNT_RESTORED, CLIENT_STATUS + CONFIRMATION, RESETPASSWD, ACTIVATED, REJECTED, GROUP_MEMBERSHIP, AUP_REMINDER, AUP_EXPIRATION, AUP_SIGNATURE_REQUEST, + ACCOUNT_SUSPENDED, ACCOUNT_RESTORED, CLIENT_STATUS, CERTIFICATE_LINK, MFA_ENABLE, MFA_DISABLE } diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java index 6a90392c4..78c656892 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java @@ -48,6 +48,7 @@ import javax.persistence.TemporalType; import javax.validation.constraints.NotNull; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.common.base.Preconditions; @Entity @@ -90,6 +91,7 @@ public class IamAccount implements Serializable { @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "user_info_id") + @JsonIgnore private IamUserInfo userInfo; @Temporal(TemporalType.TIMESTAMP) @@ -103,14 +105,17 @@ public class IamAccount implements Serializable { private Set authorities = new HashSet<>(); @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore private Set groups = new HashSet<>(); @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) + @JsonIgnore private Set samlIds = new HashSet<>(); @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) + @JsonIgnore private Set oidcIds = new HashSet<>(); @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, fetch = FetchType.EAGER, @@ -119,6 +124,7 @@ public class IamAccount implements Serializable { @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) + @JsonIgnore private Set x509Certificates = new HashSet<>(); @Column(name = "confirmation_key", unique = true, length = 36) diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java index b92328f89..f6d97f4d5 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java @@ -17,17 +17,12 @@ import java.io.Serializable; import java.util.Date; -import java.util.HashSet; -import java.util.Set; -import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; -import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; -import javax.persistence.OneToMany; import javax.persistence.OneToOne; import javax.persistence.Table; import javax.persistence.Temporal; @@ -60,10 +55,6 @@ public class IamTotpMfa implements Serializable { @Column(name = "last_update_time", nullable = false) private Date lastUpdateTime; - @OneToMany(mappedBy = "totpMfa", cascade = CascadeType.ALL, fetch = FetchType.EAGER, - orphanRemoval = true) - private Set recoveryCodes = new HashSet<>(); - public IamTotpMfa() { Date now = new Date(); setCreationTime(now); @@ -136,15 +127,6 @@ public void touch() { setLastUpdateTime(new Date()); } - public Set getRecoveryCodes() { - return recoveryCodes; - } - - public void setRecoveryCodes(final Set recoveryCodes) { - this.recoveryCodes.clear(); - this.recoveryCodes.addAll(recoveryCodes); - } - @Override public String toString() { return "IamTotpMfa [active=" + active + ", id=" + id + ", secret=" + secret + "]"; diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpRecoveryCode.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpRecoveryCode.java deleted file mode 100644 index c76bc0077..000000000 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpRecoveryCode.java +++ /dev/null @@ -1,118 +0,0 @@ -/** - * 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.persistence.model; - -import java.io.Serializable; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; -import javax.persistence.Table; - -@Entity -@Table(name = "iam_totp_recovery_code") -public class IamTotpRecoveryCode implements Serializable { - - private static final long serialVersionUID = 1L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "code") - private String code; - - @ManyToOne(fetch = FetchType.EAGER) - @JoinColumn(referencedColumnName = "id", nullable = false, name = "totp_mfa_id") - private IamTotpMfa totpMfa; - - public IamTotpRecoveryCode() {} - - public IamTotpRecoveryCode(IamTotpMfa totpMfa) { - this.totpMfa = totpMfa; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public IamTotpMfa getTotpMfa() { - return totpMfa; - } - - public void setTotpMfa(final IamTotpMfa totpMfa) { - this.totpMfa = totpMfa; - } - - public String getCode() { - return code; - } - - public void setCode(final String code) { - this.code = code; - } - - @Override - public String toString() { - return "IamTotpRecoveryCode [code=" + code + ", id=" + id + "]"; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((code == null) ? 0 : code.hashCode()); - result = prime * result + ((id == null) ? 0 : id.hashCode()); - result = prime * result + ((totpMfa == null) ? 0 : totpMfa.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - IamTotpRecoveryCode other = (IamTotpRecoveryCode) obj; - if (code == null) { - if (other.code != null) - return false; - } else if (!code.equals(other.code)) - return false; - if (id == null) { - if (other.id != null) - return false; - } else if (!id.equals(other.id)) - return false; - if (totpMfa == null) { - if (other.totpMfa != null) - return false; - } else if (!totpMfa.equals(other.totpMfa)) - return false; - return true; - } -} diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java index a8975abb1..f011a206c 100644 --- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java +++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java @@ -33,5 +33,4 @@ public class IamTotpMfaRepositoryImpl implements IamTotpMfaRepositoryCustom { public Optional findByAccount(IamAccount account) { return repo.findByAccountId(account.getId()); } - } diff --git a/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql b/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql index 552295e37..6370b4dd7 100644 --- a/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql +++ b/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql @@ -21,12 +21,12 @@ INSERT INTO client_details (id, client_id, client_secret, client_name, dynamical INSERT INTO client_details (id, client_id, client_secret, client_name, dynamically_registered, refresh_token_validity_seconds, access_token_validity_seconds, id_token_validity_seconds, allow_introspection, - token_endpoint_auth_method, require_auth_time, token_endpoint_auth_signing_alg, jwks, active) VALUES + token_endpoint_auth_method, require_auth_time, token_endpoint_auth_signing_alg, jwks) VALUES (15, 'jwt-auth-client_secret_jwt', 'c8e9eed0-e6e4-4a66-b16e-6f37096356a7', 'JWT Bearer Auth Client (client_secret_jwt)', - false, null, 3600, 600, true, 'SECRET_JWT', false, 'HS256', null, true), + false, null, 3600, 600, true, 'SECRET_JWT', false, 'HS256', null), (16, 'jwt-auth-private_key_jwt', 'secret', 'JWT Bearer Auth Client (private_key_jwt)', false, null, 3600, 600, true,'PRIVATE_KEY', false, 'RS256', - '{"keys":[{"kty":"RSA","e":"AQAB","kid":"rsa1","n":"1y1CP181zqPNPlV1JDM7Xv0QnGswhSTHe8_XPZHxDTJkykpk_1BmgA3ovP62QRE2ORgsv5oSBI_Z_RaOc4Zx2FonjEJF2oBHtBjsAiF-pxGkM5ZPjFNgFTGp1yUUBjFDcEeIGCwPEyYSt93sQIP_0DRbViMUnpyn3xgM_a1dO5brEWR2n1Uqff1yA5NXfLS03qpl2dpH4HFY5-Zs4bvtJykpAOhoHuIQbz-hmxb9MZ3uTAwsx2HiyEJtz-suyTBHO3BM2o8UcCeyfa34ShPB8i86-sf78fOk2KeRIW1Bju3ANmdV3sxL0j29cesxKCZ06u2ZiGR3Srbft8EdLPzf-w"}]}', true); + '{"keys":[{"kty":"RSA","e":"AQAB","kid":"rsa1","n":"1y1CP181zqPNPlV1JDM7Xv0QnGswhSTHe8_XPZHxDTJkykpk_1BmgA3ovP62QRE2ORgsv5oSBI_Z_RaOc4Zx2FonjEJF2oBHtBjsAiF-pxGkM5ZPjFNgFTGp1yUUBjFDcEeIGCwPEyYSt93sQIP_0DRbViMUnpyn3xgM_a1dO5brEWR2n1Uqff1yA5NXfLS03qpl2dpH4HFY5-Zs4bvtJykpAOhoHuIQbz-hmxb9MZ3uTAwsx2HiyEJtz-suyTBHO3BM2o8UcCeyfa34ShPB8i86-sf78fOk2KeRIW1Bju3ANmdV3sxL0j29cesxKCZ06u2ZiGR3Srbft8EdLPzf-w"}]}'); INSERT INTO client_scope (owner_id, scope) VALUES (1, 'openid'), @@ -244,9 +244,8 @@ INSERT INTO iam_account_group(account_id, group_id) VALUES (2,2); INSERT INTO iam_account_authority(account_id, authority_id) VALUES -(2,2); - - +(2,2), +(1000, 2); -- Other test groups INSERT INTO iam_group(id, name, uuid, description, creationtime, lastupdatetime) VALUES diff --git a/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java b/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java index f64188517..cf913eaab 100644 --- a/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java +++ b/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java @@ -21,7 +21,6 @@ public class IamAuthRequestOptionsService implements AuthRequestOptionsService { IamClientApplicationProperties properties; - public IamAuthRequestOptionsService(IamClientApplicationProperties properties) { this.properties = properties; } diff --git a/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java b/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java index eaf0bcd41..26f9557f9 100644 --- a/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java +++ b/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java @@ -80,7 +80,6 @@ public void commence(HttpServletRequest request, HttpServletResponse response, } - @Override protected void configure(HttpSecurity http) throws Exception { // @formatter:off diff --git a/iam-test-client/src/main/resources/templates/index.html b/iam-test-client/src/main/resources/templates/index.html index 5882f73cd..2c830de3a 100644 --- a/iam-test-client/src/main/resources/templates/index.html +++ b/iam-test-client/src/main/resources/templates/index.html @@ -138,7 +138,7 @@

INDIGO IAM Test Client Application

You're now logged in as: {{home.user}}

- +

This application has received the following information:

  • access_token (JWT):