diff --git a/iam-login-service/pom.xml b/iam-login-service/pom.xml index 1ab52bc39..82a0ad7ad 100644 --- a/iam-login-service/pom.xml +++ b/iam-login-service/pom.xml @@ -206,6 +206,11 @@ spring-security-oauth2 + + org.springframework.security + spring-security-oauth2-client + + org.springframework.security spring-security-test @@ -396,6 +401,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 8d18c6d7f..e0b607142 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 @@ -27,12 +27,14 @@ 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; @SuppressWarnings("deprecation") @Component public class AccountUtils { + IamAccountRepository accountRepo; @Autowired @@ -56,6 +58,14 @@ public boolean isAdmin(Authentication auth) { return auth.getAuthorities().contains(Authorities.ROLE_ADMIN); } + public boolean isPreAuthenticated(Authentication auth) { + if (auth == null || auth.getAuthorities() == null) { + return false; + } + + return auth.getAuthorities().contains(Authorities.ROLE_PRE_AUTHENTICATED); + } + public boolean isAuthenticated() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); @@ -63,7 +73,12 @@ public boolean isAuthenticated() { } public boolean isAuthenticated(Authentication auth) { - return !(isNull(auth) || auth instanceof AnonymousAuthenticationToken); + if (isNull(auth) || auth instanceof AnonymousAuthenticationToken) { + return false; + } else if (auth instanceof ExtendedAuthenticationToken && !auth.isAuthenticated()) { + return false; + } + return true; } public Optional getAuthenticatedUserAccount(Authentication authn) { @@ -72,7 +87,7 @@ public Optional getAuthenticatedUserAccount(Authentication authn) { } Authentication userAuthn = authn; - + if (authn instanceof OAuth2Authentication) { OAuth2Authentication oauth = (OAuth2Authentication) authn; if (oauth.getUserAuthentication() == null) { @@ -86,13 +101,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..dbfa34fce --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java @@ -0,0 +1,261 @@ +/** + * 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.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +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.recovery.RecoveryCodeGenerator; +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.RecoveryCodeVerifiedEvent; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.TotpVerifiedEvent; +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.model.IamTotpRecoveryCode; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; + +@Service +public class DefaultIamTotpMfaService implements IamTotpMfaService, ApplicationEventPublisherAware { + + public static final int RECOVERY_CODE_QUANTITY = 6; + + private final IamAccountService iamAccountService; + private final IamTotpMfaRepository totpMfaRepository; + private final SecretGenerator secretGenerator; + private final RecoveryCodeGenerator recoveryCodeGenerator; + private final CodeVerifier codeVerifier; + private ApplicationEventPublisher eventPublisher; + + @Autowired + public DefaultIamTotpMfaService(IamAccountService iamAccountService, + IamTotpMfaRepository totpMfaRepository, SecretGenerator secretGenerator, + RecoveryCodeGenerator recoveryCodeGenerator, CodeVerifier codeVerifier, + ApplicationEventPublisher eventPublisher) { + this.iamAccountService = iamAccountService; + this.totpMfaRepository = totpMfaRepository; + this.secretGenerator = secretGenerator; + this.recoveryCodeGenerator = recoveryCodeGenerator; + this.codeVerifier = codeVerifier; + this.eventPublisher = eventPublisher; + } + + 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)); + } + + private void recoveryCodeVerifiedEvent(IamAccount account, IamTotpMfa totpMfa) { + eventPublisher.publishEvent(new RecoveryCodeVerifiedEvent(this, account, totpMfa)); + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.eventPublisher = applicationEventPublisher; + } + + /** + * Generates and attaches a TOTP MFA secret to a user account, along with a set of recovery codes + * 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) { + 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(secretGenerator.generate()); + totpMfa.setAccount(account); + + Set recoveryCodes = generateRecoveryCodes(totpMfa); + totpMfa.setRecoveryCodes(recoveryCodes); + totpMfaRepository.save(totpMfa); + return totpMfa; + } + + /** + * Adds a set of recovery codes to a given account's TOTP secret. + * + * @param account the account to add recovery codes to + * @return the affected TOTP secret + */ + @Override + public IamTotpMfa addTotpMfaRecoveryCodes(IamAccount account) { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + + Set recoveryCodes = generateRecoveryCodes(totpMfa); + + // Attach to account + totpMfa.setRecoveryCodes(recoveryCodes); + totpMfa.touch(); + 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("No multi-factor secret is attached to this account"); + } + + 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("No multi-factor secret is attached to this account"); + } + + 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) { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + String mfaSecret = totpMfa.getSecret(); + + // Verify provided TOTP + if (codeVerifier.isValidCode(mfaSecret, totp)) { + totpVerifiedEvent(account, totpMfa); + return true; + } + + return false; + } + + /** + * Verifies a provided recovery code against an account + * + * @param account the account we will check against + * @param recoveryCode the recovery code to validate + * @return true if valid, false otherwise + */ + @Override + public boolean verifyRecoveryCode(IamAccount account, String recoveryCode) { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + if (!totpMfa.isActive()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + // Check for a matching recovery code + Set accountRecoveryCodes = totpMfa.getRecoveryCodes(); + for (IamTotpRecoveryCode recoveryCodeObject : accountRecoveryCodes) { + String recoveryCodeString = recoveryCodeObject.getCode(); + if (recoveryCode.equals(recoveryCodeString)) { + recoveryCodeVerifiedEvent(account, totpMfa); + return true; + } + } + + return false; + } + + private Set generateRecoveryCodes(IamTotpMfa totpMfa) { + String[] recoveryCodeStrings = recoveryCodeGenerator.generateCodes(RECOVERY_CODE_QUANTITY); + Set recoveryCodes = new HashSet<>(); + for (String code : recoveryCodeStrings) { + IamTotpRecoveryCode recoveryCode = new IamTotpRecoveryCode(totpMfa); + recoveryCode.setCode(code); + recoveryCodes.add(recoveryCode); + } + return recoveryCodes; + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpRecoveryCodeResetService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpRecoveryCodeResetService.java new file mode 100644 index 000000000..3df21b730 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpRecoveryCodeResetService.java @@ -0,0 +1,96 @@ +/** + * 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 static it.infn.mw.iam.api.account.multi_factor_authentication.DefaultIamTotpMfaService.RECOVERY_CODE_QUANTITY; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.stereotype.Service; + +import dev.samstevens.totp.recovery.RecoveryCodeGenerator; +import it.infn.mw.iam.audit.events.account.multi_factor_authentication.RecoveryCodesResetEvent; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.model.IamTotpRecoveryCode; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; + +@Service +public class DefaultIamTotpRecoveryCodeResetService + implements IamTotpRecoveryCodeResetService, ApplicationEventPublisherAware { + + private final IamAccountRepository accountRepository; + private final IamTotpMfaRepository totpMfaRepository; + private final RecoveryCodeGenerator recoveryCodeGenerator; + private ApplicationEventPublisher eventPublisher; + + @Autowired + public DefaultIamTotpRecoveryCodeResetService(IamAccountRepository accountRepository, + IamTotpMfaRepository totpMfaRepository, RecoveryCodeGenerator recoveryCodeGenerator) { + this.accountRepository = accountRepository; + this.totpMfaRepository = totpMfaRepository; + this.recoveryCodeGenerator = recoveryCodeGenerator; + } + + private void recoveryCodesResetEvent(IamAccount account, IamTotpMfa totpMfa) { + eventPublisher.publishEvent(new RecoveryCodesResetEvent(this, account, totpMfa)); + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.eventPublisher = applicationEventPublisher; + } + + /** + * Regenerates the recovery codes attached to a provided MFA-enabled IAM account + * + * @param account - the account to regenerate codes on + */ + @Override + public IamAccount resetRecoveryCodes(IamAccount account) { + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + String[] recoveryCodeStrings = recoveryCodeGenerator.generateCodes(RECOVERY_CODE_QUANTITY); + Set recoveryCodes = new HashSet<>(); + for (String code : recoveryCodeStrings) { + IamTotpRecoveryCode recoveryCode = new IamTotpRecoveryCode(totpMfa); + recoveryCode.setCode(code); + recoveryCodes.add(recoveryCode); + } + + // Attach to account + totpMfa.setRecoveryCodes(recoveryCodes); + totpMfa.touch(); + account.touch(); + accountRepository.save(account); + totpMfaRepository.save(totpMfa); + recoveryCodesResetEvent(account, totpMfa); + + return account; + } + +} 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..04ca64383 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java @@ -0,0 +1,77 @@ +/** + * 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, along with a set of recovery codes + * 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); + + /** + * Adds a set of recovery codes to a given account's TOTP secret. + * + * @param account the account to add recovery codes to + * @return the affected TOTP secret + */ + IamTotpMfa addTotpMfaRecoveryCodes(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); + + /** + * Verifies a provided recovery code against an account + * + * @param account the account we will check against + * @param recoveryCode the recovery code to validate + * @return true if valid, false otherwise + */ + boolean verifyRecoveryCode(IamAccount account, String recoveryCode); +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpRecoveryCodeResetService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpRecoveryCodeResetService.java new file mode 100644 index 000000000..0b77262ef --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpRecoveryCodeResetService.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.api.account.multi_factor_authentication; + +import it.infn.mw.iam.persistence.model.IamAccount; + +public interface IamTotpRecoveryCodeResetService { + + /** + * Regenerates the recovery codes attached to a provided MFA-enabled IAM account + * + * @param account - the account to regenerate codes on + */ + public IamAccount resetRecoveryCodes(IamAccount account); +} 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..804da3110 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java @@ -0,0 +1,99 @@ +/** + * 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.beans.factory.annotation.Autowired; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +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"; + private final IamAccountRepository accountRepository; + private final IamTotpMfaRepository totpMfaRepository; + + @Autowired + 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('USER')") + @RequestMapping(value = MULTI_FACTOR_SETTINGS_URL, method = RequestMethod.GET, + 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..463dd9139 --- /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,284 @@ +/** + * 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.beans.factory.annotation.Autowired; +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.ExceptionHandler; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +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.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; + +/** + * 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"; + + private final IamTotpMfaService service; + private final IamAccountRepository accountRepository; + private final QrGenerator qrGenerator; + + @Autowired + public AuthenticatorAppSettingsController(IamTotpMfaService service, + IamAccountRepository accountRepository, QrGenerator qrGenerator) { + this.service = service; + this.accountRepository = accountRepository; + this.qrGenerator = qrGenerator; + } + + + /** + * 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')") + @RequestMapping(value = ADD_SECRET_URL, method = RequestMethod.PUT, + produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public SecretAndDataUriDTO addSecret() { + final String username = getUsernameFromSecurityContext(); + IamAccount account = accountRepository.findByUsername(username) + .orElseThrow(() -> NoSuchAccountError.forUsername(username)); + + IamTotpMfa totpMfa = service.addTotpMfaSecret(account); + SecretAndDataUriDTO dto = new SecretAndDataUriDTO(totpMfa.getSecret()); + + try { + String dataUri = generateQRCodeFromSecret(totpMfa.getSecret(), account.getUsername()); + dto.setDataUri(dataUri); + } catch (QrGenerationException e) { + throw new BadMfaCodeError("Could not generate QR code"); + } + + return dto; + } + + + /** + * 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')") + @RequestMapping(value = ENABLE_URL, method = RequestMethod.POST, + 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 e; + } + + if (!valid) { + throw new BadMfaCodeError("Bad code"); + } + + service.enableTotpMfa(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')") + @RequestMapping(value = DISABLE_URL, method = RequestMethod.POST, + 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 e; + } + + if (!valid) { + throw new BadMfaCodeError("Bad code"); + } + + service.disableTotpMfa(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 { + + // TODO add in admin configuration through properties file + QrData data = new QrData.Builder().label(username) + .secret(secret) + .issuer("IAM Test") + .algorithm(HashingAlgorithm.SHA1) + .digits(6) + .period(30) + .build(); + + byte[] imageData; + + try { + imageData = qrGenerator.generate(data); + } catch (QrGenerationException e) { + throw e; + } + + 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/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..895e6b431 --- /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/RecoveryCodeVerifiedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/RecoveryCodeVerifiedEvent.java new file mode 100644 index 000000000..f24a371e6 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/RecoveryCodeVerifiedEvent.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 RecoveryCodeVerifiedEvent extends MultiFactorEvent { + + public static final String TEMPLATE = "MFA recovery code verified for account '%s'"; + + private static final long serialVersionUID = 1L; + + public RecoveryCodeVerifiedEvent(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/RecoveryCodesResetEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/RecoveryCodesResetEvent.java new file mode 100644 index 000000000..c9282842e --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/RecoveryCodesResetEvent.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 RecoveryCodesResetEvent extends MultiFactorEvent { + + public static final String TEMPLATE = "MFA recovery codes reset on account '%s'"; + + private static final long serialVersionUID = 1L; + + public RecoveryCodesResetEvent(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/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..9fbd08ec7 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java @@ -0,0 +1,128 @@ +/** + * 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.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.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; +import it.infn.mw.iam.authn.util.Authorities; + +/** + * 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 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()) { + System.out + .println("Response has already been committed. Unable to redirect to " + MFA_VERIFY_URL); + return; + } 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..5347f2cdf --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequest.java @@ -0,0 +1,122 @@ +/** + * 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); + + Map commonQueryParamMap = new LinkedHashMap<>(paramMap); + + return commonQueryParamMap; + } + + @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..6d9e623c5 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java @@ -0,0 +1,91 @@ +/** + * 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(); + amrClaim += current.getName() + "+"; + } + + // 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..d45472117 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java @@ -0,0 +1,53 @@ +/** + * 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; + +public class IamAuthenticationMethodReference { + + 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..c92ee1e04 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java @@ -0,0 +1,101 @@ +/** + * 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.beans.factory.annotation.Autowired; +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.ExceptionHandler; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +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; + +//TODO when unauthenticated and navigating to other pages like /dashboard, we redirect to /login. But here we show up as unauthorized. Can we replicate the behaviour of /dashboard? + +/** + * 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; + + @Autowired + public MfaVerifyController(IamAccountRepository accountRepository, + IamTotpMfaRepository totpMfaRepository) { + this.accountRepository = accountRepository; + this.totpMfaRepository = totpMfaRepository; + } + + @PreAuthorize("hasRole('PRE_AUTHENTICATED')") + @RequestMapping(method = RequestMethod.GET, path = "") + 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/MultiFactorRecoveryCodeCheckProvider.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorRecoveryCodeCheckProvider.java new file mode 100644 index 000000000..5bd27567c --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorRecoveryCodeCheckProvider.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 recovery code. Only comes into play in the + * step-up authentication flow. + */ +public class MultiFactorRecoveryCodeCheckProvider implements AuthenticationProvider { + + private final IamAccountRepository accountRepo; + private final IamTotpMfaService totpMfaService; + + public MultiFactorRecoveryCodeCheckProvider(IamAccountRepository accountRepo, + IamTotpMfaService totpMfaService) { + this.accountRepo = accountRepo; + this.totpMfaService = totpMfaService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + ExtendedAuthenticationToken token = (ExtendedAuthenticationToken) authentication; + + String recoveryCode = token.getRecoveryCode(); + if (recoveryCode == null) { + return null; + } + + IamAccount account = accountRepo.findByUsername(authentication.getName()) + .orElseThrow(() -> new BadCredentialsException("Invalid login details")); + + boolean valid = false; + + try { + valid = totpMfaService.verifyRecoveryCode(account, recoveryCode); + } catch (MfaSecretNotFoundException e) { + throw e; + } + + if (!valid) { + throw new BadCredentialsException("Bad recovery code"); + } + + 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/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..5849a0446 --- /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 e; + } + + if (!valid) { + throw new BadCredentialsException("Bad code"); + } + + 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..f29829a83 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.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.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 or recovery code 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_RECOVERY_CODE_KEY = "recoveryCode"; + public static final String TOTP_VERIFIED = "TOTP_VERIFIED"; + public static final String RECOVERY_CODE_VERIFIED = "RECOVERY_CODE_VERIFIED"; + + public static final AntPathRequestMatcher DEFAULT_MFA_VERIFY_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(MFA_VERIFY_URL, "POST"); + + private final boolean postOnly = true; + + private String totpParameter = TOTP_MFA_CODE_KEY; + private String recoveryCodeParameter = TOTP_RECOVERY_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 (this.postOnly && !request.getMethod().equals("POST")) { + throw new AuthenticationServiceException( + "Authentication method not supported: " + request.getMethod()); + } + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth instanceof ExtendedAuthenticationToken)) { + throw new AuthenticationServiceException("Bad authentication"); + } + + ExtendedAuthenticationToken authRequest = (ExtendedAuthenticationToken) auth; + + // Parse TOTP and recovery code from request (only one should be set) + String totp = parseTotp(request); + String recoveryCode = parseRecoveryCode(request); + + if (totp != null) { + authRequest.setTotp(totp); + } else if (recoveryCode != null) { + authRequest.setRecoveryCode(recoveryCode); + } else { + throw new ProviderNotFoundException("No valid totp code or recovery code was received"); + } + + Authentication fullAuthentication = this.getAuthenticationManager().authenticate(authRequest); + if (fullAuthentication == null) { + throw new ProviderNotFoundException("No valid totp code or recovery code was received"); + } + + if (authRequest.getTotp() != null) { + request.setAttribute(TOTP_VERIFIED, Boolean.TRUE); + } else if (authRequest.getRecoveryCode() != null) { + request.setAttribute(RECOVERY_CODE_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; + } + + private String parseRecoveryCode(HttpServletRequest request) { + String recoveryCode = request.getParameter(this.recoveryCodeParameter); + return recoveryCode != null ? recoveryCode.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..aa7a8695c --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java @@ -0,0 +1,107 @@ +/** + * 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 static it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter.RECOVERY_CODE_VERIFIED; +import static it.infn.mw.iam.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_RESET_URL; + +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.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; + +/** + * Determines if a recovery code was used to authenticate. If so, we need to redirect to the page + * that asks if the user wants to reset their recovery codes or skip this step to continue onwards. + */ +public class MultiFactorVerificationSuccessHandler implements AuthenticationSuccessHandler { + + 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()) { + System.out + .println("Response has already been committed. Unable to redirect to " + MFA_VERIFY_URL); + return; + } else { + // If a recovery code was used, RECOVERY_CODE_VERIFIED attribute will exist in the request + Object recoveryCodeVerifiedAttribute = request.getAttribute(RECOVERY_CODE_VERIFIED); + if (recoveryCodeVerifiedAttribute != null + && (boolean) recoveryCodeVerifiedAttribute == Boolean.TRUE) { + response.sendRedirect(RECOVERY_CODE_RESET_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); + request.removeAttribute(RECOVERY_CODE_VERIFIED); + } +} + diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ResetOrSkipRecoveryCodesFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ResetOrSkipRecoveryCodesFilter.java new file mode 100644 index 000000000..91200adfe --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ResetOrSkipRecoveryCodesFilter.java @@ -0,0 +1,132 @@ +/** + * 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.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_RESET_URL; +import static it.infn.mw.iam.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_VIEW_URL; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +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.api.account.multi_factor_authentication.IamTotpRecoveryCodeResetService; +import it.infn.mw.iam.api.common.error.NoAuthenticatedUserError; +import it.infn.mw.iam.authn.EnforceAupSignatureSuccessHandler; +import it.infn.mw.iam.authn.RootIsDashboardSuccessHandler; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; + +/** + * Filter for handling user response from page that asks user to reset recovery codes or skip this + * step. This is received through a POST request and then the request is redirected appropriately + */ +public class ResetOrSkipRecoveryCodesFilter extends GenericFilterBean { + + public static final String RESET_KEY = "reset"; + public static final String SKIP_KEY = "skip"; + private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(RECOVERY_CODE_RESET_URL, "POST"); + + private String resetParameter = RESET_KEY; + private String skipParameter = SKIP_KEY; + + private final AccountUtils accountUtils; + private final AUPSignatureCheckService aupSignatureCheckService; + private final IamAccountRepository accountRepo; + private final String iamBaseUrl; + private final IamTotpRecoveryCodeResetService recoveryCodeResetService; + + public ResetOrSkipRecoveryCodesFilter(AccountUtils accountUtils, + AUPSignatureCheckService aupSignatureCheckService, IamAccountRepository accountRepo, + String iamBaseUrl, IamTotpRecoveryCodeResetService recoveryCodeResetService) { + this.accountUtils = accountUtils; + this.aupSignatureCheckService = aupSignatureCheckService; + this.accountRepo = accountRepo; + this.iamBaseUrl = iamBaseUrl; + this.recoveryCodeResetService = recoveryCodeResetService; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!requiresProcessing(request, response)) { + chain.doFilter(request, response); + return; + } + + String reset = request.getParameter(resetParameter); + String skip = request.getParameter(skipParameter); + + if (reset != null) { + // User chose to reset, retrieve authenticated account so we can reset its codes then redirect + // to the recovery code view page + IamAccount account = + accountUtils.getAuthenticatedUserAccount().orElseThrow(NoAuthenticatedUserError::new); + recoveryCodeResetService.resetRecoveryCodes(account); + response.sendRedirect(RECOVERY_CODE_VIEW_URL); + } else if (skip != null) { + // User chose not to reset, continue with normal success handler + chain.doFilter(request, response); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + continueWithDefaultSuccessHandler(request, response, auth); + return; + } else { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "No valid parameter was received"); + } + } + + private boolean requiresProcessing(HttpServletRequest request, HttpServletResponse response) { + if (DEFAULT_ANT_PATH_REQUEST_MATCHER.matches(request)) { + return true; + } + if (this.logger.isTraceEnabled()) { + this.logger + .trace(LogMessage.format("Did not match request to %s", DEFAULT_ANT_PATH_REQUEST_MATCHER)); + } + return false; + } + + 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); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ViewRecoveryCodesFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ViewRecoveryCodesFilter.java new file mode 100644 index 000000000..c6b182b7b --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ViewRecoveryCodesFilter.java @@ -0,0 +1,104 @@ +/** + * 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.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_VIEW_URL; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +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.RootIsDashboardSuccessHandler; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; + +/** + * Filter for handling user response from page to view recovery codes post-authentication and + * post-reset. Only response is to continue with the normal success handler. + */ +public class ViewRecoveryCodesFilter extends GenericFilterBean { + + private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(RECOVERY_CODE_VIEW_URL, "POST"); + + private final AccountUtils accountUtils; + private final AUPSignatureCheckService aupSignatureCheckService; + private final IamAccountRepository accountRepo; + private final String iamBaseUrl; + + public ViewRecoveryCodesFilter(AccountUtils accountUtils, + AUPSignatureCheckService aupSignatureCheckService, IamAccountRepository accountRepo, + String iamBaseUrl) { + this.accountUtils = accountUtils; + this.aupSignatureCheckService = aupSignatureCheckService; + this.accountRepo = accountRepo; + this.iamBaseUrl = iamBaseUrl; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!requiresProcessing(request, response)) { + chain.doFilter(request, response); + return; + } + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + continueWithDefaultSuccessHandler(request, response, auth); + } + + private boolean requiresProcessing(HttpServletRequest request, HttpServletResponse response) { + if (DEFAULT_ANT_PATH_REQUEST_MATCHER.matches(request)) { + return true; + } + if (this.logger.isTraceEnabled()) { + this.logger + .trace(LogMessage.format("Did not match request to %s", DEFAULT_ANT_PATH_REQUEST_MATCHER)); + } + return false; + } + + 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); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/authenticator_app/RecoveryCodeManagementController.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/authenticator_app/RecoveryCodeManagementController.java new file mode 100644 index 000000000..77db9460d --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/authenticator_app/RecoveryCodeManagementController.java @@ -0,0 +1,156 @@ +/** + * 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.authenticator_app; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpRecoveryCodeResetService; +import it.infn.mw.iam.api.common.ErrorDTO; +import it.infn.mw.iam.authn.multi_factor_authentication.error.MultiFactorAuthenticationError; +import it.infn.mw.iam.authn.multi_factor_authentication.error.NoMultiFactorSecretError; +import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.model.IamTotpRecoveryCode; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; + +/** + * Provides webpages related to recovery codes. Most of this appears if the user chooses to use a + * recovery code in the MFA login flow. Also partially used in the multi-factor settings menu on the + * dashboard. + */ +@Controller +public class RecoveryCodeManagementController { + + public static final String RECOVERY_CODE_RESET_URL = "/iam/authenticator-app/recovery-code/reset"; + public static final String RECOVERY_CODE_VIEW_URL = "/iam/authenticator-app/recovery-code/view"; + public static final String RECOVERY_CODE_GET_URL = "/iam/authenticator-app/recovery-code/get"; + + private final AccountUtils accountUtils; + private final IamTotpMfaRepository totpMfaRepository; + private final IamTotpRecoveryCodeResetService recoveryCodeResetService; + + @Autowired + public RecoveryCodeManagementController(AccountUtils accountUtils, + IamTotpMfaRepository totpMfaRepository, + IamTotpRecoveryCodeResetService recoveryCodeResetService) { + this.accountUtils = accountUtils; + this.totpMfaRepository = totpMfaRepository; + this.recoveryCodeResetService = recoveryCodeResetService; + } + + /** + * @return page for asking if the user wishes to reset their recovery codes or skip this step + */ + @PreAuthorize("hasRole('USER')") + @RequestMapping(method = RequestMethod.GET, path = RECOVERY_CODE_RESET_URL) + public String getResetRecoveryCodesResetView() { + return RECOVERY_CODE_RESET_URL; + } + + /** + * Calls method to fetch account MFA recovery codes to display on returned page + * + * @param model to add recovery codes array to + * @return page for viewing account MFA recovery codes post-reset + */ + @PreAuthorize("hasRole('USER')") + @RequestMapping(method = RequestMethod.GET, path = RECOVERY_CODE_VIEW_URL) + public String viewRecoveryCodes(ModelMap model) { + String[] codes = getRecoveryCodes(); + + model.addAttribute("recoveryCodes", codes); + return RECOVERY_CODE_VIEW_URL; + } + + /** + * Populates and returns the array of recovery codes attached to the authenticated user account + * Also called in the MFA settings menu for display + * + * @return the array of recovery codes + */ + @PreAuthorize("hasRole('USER')") + @RequestMapping(method = RequestMethod.GET, path = RECOVERY_CODE_GET_URL) + public @ResponseBody String[] getRecoveryCodes() { + IamAccount account = accountUtils.getAuthenticatedUserAccount() + .orElseThrow(() -> new MultiFactorAuthenticationError("Account not found")); + + Optional totpMfaOptional = totpMfaRepository.findByAccount(account); + if (!totpMfaOptional.isPresent()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + IamTotpMfa totpMfa = totpMfaOptional.get(); + if (!totpMfa.isActive()) { + throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account"); + } + + List recs = new ArrayList<>(totpMfa.getRecoveryCodes()); + String[] codes = new String[recs.size()]; + + for (int i = 0; i < recs.size(); i++) { + codes[i] = recs.get(i).getCode(); + } + + return codes; + } + + /** + * Request to reset the recovery codes on the authenticated account. TODO may wish to protect this + * endpoint a bit more to prevent this being done outside of the regular flow + * + * @return 200 response upon success + */ + @PreAuthorize("hasRole('USER')") + @RequestMapping(method = RequestMethod.PUT, path = RECOVERY_CODE_RESET_URL) + public ResponseEntity resetRecoveryCodes() { + IamAccount account = accountUtils.getAuthenticatedUserAccount() + .orElseThrow(() -> new MultiFactorAuthenticationError("Account not found")); + + recoveryCodeResetService.resetRecoveryCodes(account); + + return ResponseEntity.ok().build(); + } + + @ResponseStatus(code = HttpStatus.BAD_REQUEST) + @ExceptionHandler(MultiFactorAuthenticationError.class) + @ResponseBody + public ErrorDTO handleMultiFactorAuthenticationError(MultiFactorAuthenticationError e) { + return ErrorDTO.fromString(e.getMessage()); + } + + @ResponseStatus(code = HttpStatus.NOT_FOUND) + @ExceptionHandler(NoMultiFactorSecretError.class) + @ResponseBody + public ErrorDTO handleNoMultiFactorSecretError(NoMultiFactorSecretError e) { + return ErrorDTO.fromString(e.getMessage()); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/error/MultiFactorAuthenticationError.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/error/MultiFactorAuthenticationError.java new file mode 100644 index 000000000..da5c9420f --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/error/MultiFactorAuthenticationError.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.authn.multi_factor_authentication.error; + +import org.springframework.security.core.AuthenticationException; + +public class MultiFactorAuthenticationError extends AuthenticationException { + + private static final long serialVersionUID = 1L; + + public MultiFactorAuthenticationError(String msg) { + super(msg); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/error/NoMultiFactorSecretError.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/error/NoMultiFactorSecretError.java new file mode 100644 index 000000000..4829ae134 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/error/NoMultiFactorSecretError.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.authn.multi_factor_authentication.error; + +public class NoMultiFactorSecretError extends MultiFactorAuthenticationError { + + private static final long serialVersionUID = 1L; + + public NoMultiFactorSecretError(String msg) { + super(msg); + } +} 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 a6793d664..1a4467247 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,28 +28,22 @@ import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType; import it.infn.mw.iam.config.login.LoginButtonProperties; +import it.infn.mw.iam.config.mfa.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 LocalAuthenticationLoginPageMode { - VISIBLE, - HIDDEN, - HIDDEN_WITH_LINK + VISIBLE, HIDDEN, HIDDEN_WITH_LINK } public static class AccountLinkingProperties { @@ -527,6 +521,8 @@ public void setLocation(String location) { private LoginButtonProperties loginButton = new LoginButtonProperties(); + private VerifyButtonProperties verifyButton = new VerifyButtonProperties(); + private RegistractionAccessToken token = new RegistractionAccessToken(); private PrivacyPolicy privacyPolicy = new PrivacyPolicy(); @@ -623,6 +619,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..48672d0b3 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java @@ -0,0 +1,193 @@ +/** + * 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.recovery.RecoveryCodeGenerator; +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.api.account.multi_factor_authentication.IamTotpRecoveryCodeResetService; +import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorRecoveryCodeCheckProvider; +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.authn.multi_factor_authentication.ResetOrSkipRecoveryCodesFilter; +import it.infn.mw.iam.authn.multi_factor_authentication.ViewRecoveryCodesFilter; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; +import it.infn.mw.iam.service.aup.AUPSignatureCheckService; + +// TODO add admin config options from properties file + +/** + * Beans for handling TOTP MFA functionality + */ +@Configuration +public class IamTotpMfaConfig { + + @Value("${iam.baseUrl}") + private String iamBaseUrl; + + @Autowired + private IamAccountRepository accountRepo; + + @Autowired + private IamTotpMfaService totpMfaService; + + @Autowired + private AUPSignatureCheckService aupSignatureCheckService; + + @Autowired + private IamTotpRecoveryCodeResetService recoveryCodeResetService; + + @Autowired + private AccountUtils accountUtils; + + /** + * Responsible for generating new TOTP secrets + * + * @return SecretGenerator + */ + @Bean + @Qualifier("secretGenerator") + public 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") + public 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") + public CodeVerifier codeVerifier() { + return new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider()); + } + + + /** + * Responsible for generating random recovery codes for backup authentication + * + * @return RecoveryCodeGenerator + */ + @Bean + @Qualifier("recoveryCodeGenerator") + public RecoveryCodeGenerator recoveryCodeGenerator() { + return new RecoveryCodeGenerator(); + } + + @Bean(name = "MultiFactorVerificationFilter") + public MultiFactorVerificationFilter multiFactorVerificationFilter( + @Qualifier("MultiFactorVerificationAuthenticationManager") AuthenticationManager authenticationManager) { + + MultiFactorVerificationFilter filter = new MultiFactorVerificationFilter(authenticationManager, + successHandler(), failureHandler()); + + return filter; + } + + @Bean(name = "ResetOrSkipRecoveryCodesFilter") + public ResetOrSkipRecoveryCodesFilter resetOrSkipRecoveryCodesFilter() { + + ResetOrSkipRecoveryCodesFilter filter = new ResetOrSkipRecoveryCodesFilter(accountUtils, + aupSignatureCheckService, accountRepo, iamBaseUrl, recoveryCodeResetService); + + return filter; + } + + @Bean(name = "ViewRecoveryCodesFilter") + public ViewRecoveryCodesFilter viewRecoveryCodesFilter() { + + ViewRecoveryCodesFilter filter = new ViewRecoveryCodesFilter(accountUtils, + aupSignatureCheckService, accountRepo, iamBaseUrl); + + return filter; + } + + /** + * Authentication manager for the MFA verification process + * + * @param totpCheckProvider checks a provided TOTP + * @param recoveryCodeCheckProvider checks a provided recovery code + * @return a new provider manager + */ + @Bean(name = "MultiFactorVerificationAuthenticationManager") + public AuthenticationManager authenticationManager(MultiFactorTotpCheckProvider totpCheckProvider, + MultiFactorRecoveryCodeCheckProvider recoveryCodeCheckProvider) { + return new ProviderManager(Arrays.asList(totpCheckProvider, recoveryCodeCheckProvider)); + } + + 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 + public MultiFactorTotpCheckProvider totpCheckProvider() { + return new MultiFactorTotpCheckProvider(accountRepo, totpMfaService); + } + + @Bean + public MultiFactorRecoveryCodeCheckProvider recoveryCodeCheckProvider() { + return new MultiFactorRecoveryCodeCheckProvider(accountRepo, totpMfaService); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/VerifyButtonProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/VerifyButtonProperties.java new file mode 100644 index 000000000..41ce121c5 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/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.mfa; + +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..1e718f680 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,9 @@ 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 static it.infn.mw.iam.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_RESET_URL; +import static it.infn.mw.iam.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_VIEW_URL; import javax.servlet.RequestDispatcher; @@ -41,18 +44,24 @@ 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.multi_factor_authentication.ResetOrSkipRecoveryCodesFilter; +import it.infn.mw.iam.authn.multi_factor_authentication.ViewRecoveryCodesFilter; 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 +71,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() { @@ -106,6 +114,9 @@ public static class UserLoginConfig extends WebSecurityConfigurerAdapter { @Autowired private IamAccountRepository accountRepo; + @Autowired + private IamTotpMfaRepository totpMfaRepository; + @Autowired private AUPSignatureCheckService aupSignatureCheckService; @@ -121,7 +132,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 +186,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 +207,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 +353,89 @@ 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; + + @Autowired + @Qualifier("ResetOrSkipRecoveryCodesFilter") + private ResetOrSkipRecoveryCodesFilter resetOrSkipRecoveryCodesFilter; + + public AuthenticationEntryPoint mfaAuthenticationEntryPoint() { + LoginUrlAuthenticationEntryPoint entryPoint = + new LoginUrlAuthenticationEntryPoint(MFA_VERIFY_URL); + return entryPoint; + } + + @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); + } + } + + /** + * Configure the endpoint where users choose to either reset their recovery codes or continue with + * authentication process. Only used when a user provides a recovery code as verification + */ + @Configuration + @Order(103) + public static class RecoveryCodeConfigurationAdapter extends WebSecurityConfigurerAdapter { + + @Autowired + @Qualifier("ResetOrSkipRecoveryCodesFilter") + private ResetOrSkipRecoveryCodesFilter resetOrSkipRecoveryCodesFilter; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.antMatcher(RECOVERY_CODE_RESET_URL + "**") + .authorizeRequests() + .anyRequest() + .hasRole("USER") + .and() + .addFilterAfter(resetOrSkipRecoveryCodesFilter, UsernamePasswordAuthenticationFilter.class); + } + } + + /** + * Configure the endpoint for viewing recovery codes during the auth flow, following a recovery + * code reset + */ + @Configuration + @Order(104) + public static class RecoveryCodeViewConfigurationAdapter extends WebSecurityConfigurerAdapter { + + @Autowired + @Qualifier("ViewRecoveryCodesFilter") + private ViewRecoveryCodesFilter viewRecoveryCodesFilter; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.antMatcher(RECOVERY_CODE_VIEW_URL + "**") + .authorizeRequests() + .anyRequest() + .hasRole("USER") + .and() + .addFilterAfter(viewRecoveryCodesFilter, UsernamePasswordAuthenticationFilter.class); + } + } } 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..4250992cf --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java @@ -0,0 +1,155 @@ +/** + * 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.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 String recoveryCode} - if authenticating with a recovery code, 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 final Object principal; + private Object credentials; + private Set authenticationMethodReferences = new HashSet<>(); + private String totp; + private String recoveryCode; + 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 ExtendedAuthenticationToken(Object principal, Object credentials, + // Set authenticationMethodReferences) { + // super(null); + // this.principal = principal; + // this.credentials = credentials; + // this.authenticationMethodReferences = authenticationMethodReferences; + // } + + // public ExtendedAuthenticationToken(Object principal, Object credentials, + // Collection authorities, + // Set authenticationMethodReferences) { + // super(authorities); + // this.principal = principal; + // this.credentials = credentials; + // this.authenticationMethodReferences = authenticationMethodReferences; + // } + + public ExtendedAuthenticationToken(ExtendedAuthenticationToken other) { + super(other.getAuthorities()); + this.principal = other.getPrincipal(); + this.credentials = other.getCredentials(); + this.authenticationMethodReferences = other.getAuthenticationMethodReferences(); + this.totp = other.getTotp(); + this.recoveryCode = other.getRecoveryCode(); + this.fullyAuthenticatedAuthorities = other.getFullyAuthenticatedAuthorities(); + } + + 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; + } + + public String getRecoveryCode() { + return recoveryCode; + } + + public void setRecoveryCode(String recoveryCode) { + this.recoveryCode = recoveryCode; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @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("Recovery code=").append(this.getRecoveryCode()); + 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/FormClientCredentialsAuthenticationFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/FormClientCredentialsAuthenticationFilter.java index 91f9e9c61..0f35c22ec 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/FormClientCredentialsAuthenticationFilter.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/FormClientCredentialsAuthenticationFilter.java @@ -24,13 +24,14 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.security.authentication.InsufficientAuthenticationException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import it.infn.mw.iam.core.ExtendedAuthenticationToken; + public class FormClientCredentialsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { @@ -61,8 +62,8 @@ public Authentication attemptAuthentication(HttpServletRequest request, throw new InsufficientAuthenticationException("No client credentials found in request"); } - UsernamePasswordAuthenticationToken authRequest = - new UsernamePasswordAuthenticationToken(clientId.trim(), clientSecret); + ExtendedAuthenticationToken authRequest = + new ExtendedAuthenticationToken(clientId.trim(), clientSecret); return this.getAuthenticationManager().authenticate(authRequest); } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/iam/IamJWTProfileIdTokenCustomizer.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/iam/IamJWTProfileIdTokenCustomizer.java index 9d00d5a1b..2878c99a2 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/iam/IamJWTProfileIdTokenCustomizer.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/iam/IamJWTProfileIdTokenCustomizer.java @@ -15,17 +15,18 @@ */ package it.infn.mw.iam.core.oauth.profile.iam; +import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING; import static it.infn.mw.iam.core.oauth.profile.iam.ClaimValueHelper.ADDITIONAL_CLAIMS; import java.util.Set; +import com.nimbusds.jwt.JWTClaimsSet.Builder; + import org.mitre.oauth2.model.ClientDetailsEntity; import org.mitre.oauth2.model.OAuth2AccessTokenEntity; import org.mitre.openid.connect.service.ScopeClaimTranslationService; import org.springframework.security.oauth2.provider.OAuth2Request; -import com.nimbusds.jwt.JWTClaimsSet.Builder; - import it.infn.mw.iam.config.IamProperties; import it.infn.mw.iam.core.oauth.profile.common.BaseIdTokenCustomizer; import it.infn.mw.iam.persistence.model.IamAccount; @@ -59,6 +60,15 @@ public void customizeIdTokenClaims(Builder idClaims, ClientDetailsEntity client, .filter(ADDITIONAL_CLAIMS::contains) .forEach(c -> idClaims.claim(c, claimValueHelper.getClaimValueFromUserInfo(c, info))); + // Add the methods of authentication to the id_token. These were added to the OAuth2 request + // from the ExtendedHttpServletRequest + String amrParam = + request.getRequestParameters().get(AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING); + if (amrParam != null) { + String[] amrArr = amrParam.split("\\+"); + idClaims.claim(AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING, amrArr); + } + includeLabelsInIdToken(idClaims, account); } 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 d608893ea..edcc481d0 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 @@ -33,14 +33,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. * @@ -93,29 +94,32 @@ public interface IamAccountService { * @return the updated account */ IamAccount deleteLabel(IamAccount account, IamLabel label); - + /** * Sets end time for a given account + * * @param account * @param endTime * @return the updated account */ IamAccount setAccountEndTime(IamAccount account, Date endTime); - + /** * 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/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 c481a8078..1b13d2d0f 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 @@ -26,6 +26,7 @@ import it.infn.mw.iam.config.IamProperties; import it.infn.mw.iam.config.client_registration.ClientRegistrationProperties; import it.infn.mw.iam.config.saml.IamSamlProperties; +import it.infn.mw.iam.core.web.multi_factor_authentication.MultiFactorVerificationPageConfiguration; import it.infn.mw.iam.core.web.loginpage.LoginPageConfiguration; import it.infn.mw.iam.rcauth.RCAuthProperties; @@ -33,6 +34,8 @@ 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"; @@ -51,13 +54,16 @@ public class IamViewInfoInterceptor implements HandlerInterceptor { @Value("${iam.organisation.name}") String organisationName; - + @Autowired LoginPageConfiguration loginPageConfiguration; + @Autowired + MultiFactorVerificationPageConfiguration multiFactorVerificationPageConfiguration; + @Autowired IamSamlProperties samlProperties; - + @Autowired RCAuthProperties rcAuthProperties; @@ -70,18 +76,20 @@ 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()); if (iamProperties.getVersionedStaticResources().isEnableVersioning()) { diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/recovery-code/reset.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/recovery-code/reset.jsp new file mode 100644 index 000000000..f96d4e0f0 --- /dev/null +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/recovery-code/reset.jsp @@ -0,0 +1,44 @@ +<%-- + + 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. + +--%> +<%@ taglib prefix="authz" uri="http://www.springframework.org/security/tags"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> +<%@ taglib prefix="t" tagdir="/WEB-INF/tags/iam"%> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> + + + + + + + +

+
+ To strengthen account security, it is strongly recommended you reset your account recovery codes after one is used. To do this, press Reset. Otherwise, press Skip. +
+ + +
+ +
+ + +
+ + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/recovery-code/view.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/recovery-code/view.jsp new file mode 100644 index 000000000..a051b485c --- /dev/null +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/recovery-code/view.jsp @@ -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. + +--%> +<%@ taglib prefix="authz" uri="http://www.springframework.org/security/tags"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> +<%@ taglib prefix="t" tagdir="/WEB-INF/tags/iam"%> +<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> + + + + + + + +
+
+ Here are your account recovery codes. It is important you write these down as they will help you get back into your account if you lose access to your authenticator app. +
+ +

${ code }

+
+
+
+
+ +
+
+
+
\ No newline at end of file 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..90119f9bf --- /dev/null +++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp @@ -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. + +--%> + +
+
+
+ For your security, please enter a code from your authenticator app +
+
+
+ + + + +
+
+ + +
+ +
+
+ Lost access to your authenticator app? Enter a recovery code to regain access +
+
+
+ + + + + +
+
+ + + +
+
+ +
+ + + + \ 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 57ef2e3e1..8a8939253 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 @@ -111,15 +111,18 @@ + + + @@ -136,6 +139,8 @@ + + + + + +
+ +
+ + + +
${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/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..536bc853d --- /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..894281b73 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js @@ -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. + */ +(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.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) { + 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 a58740c91..423ce321b 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 @@ -67,6 +67,9 @@

+ + + 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..60a7d4a3a --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js @@ -0,0 +1,234 @@ +/* + * 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); + + angular.module('dashboardApp') + .controller('ViewRecoveryCodesController', ViewRecoveryCodesController); + + angular.module('dashboardApp') + .controller('ResetRecoveryCodesController', ResetRecoveryCodesController); + + EnableAuthenticatorAppController.$inject = [ + '$scope', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user', '$uibModal' + ]; + + DisableAuthenticatorAppController.$inject = [ + '$scope', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user' + ]; + + ViewRecoveryCodesController.$inject = [ + '$uibModalInstance', '$uibModal', 'AuthenticatorAppService' + ]; + + ResetRecoveryCodesController.$inject = [ + '$uibModalInstance', 'Utils', 'AuthenticatorAppService' + ]; + + 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; + authAppCtrl.viewRecoveryCodes = viewRecoveryCodes; + + 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'); } + + function viewRecoveryCodes() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/view-recovery-codes.html', + controller: 'ViewRecoveryCodesController', + controllerAs: 'authAppCtrl', + resolve: { user: function () { return self.user; } } + }); + + modalInstance.result.then(function (msg) { + return $uibModalInstance.close(msg); + }); + } + + authAppCtrl.message = ''; + + authAppCtrl.submitEnable = function () { + authAppCtrl.requestPending = true; + AuthenticatorAppService + .enableAuthenticatorApp( + authAppCtrl.user.code) + .then(function () { + authAppCtrl.requestPending = false; + authAppCtrl.viewRecoveryCodes(); + $uibModalInstance.close('Authenticator app 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.submitDisable = function () { + authAppCtrl.requestPending = true; + AuthenticatorAppService + .disableAuthenticatorApp( + authAppCtrl.user.code) + .then(function () { + authAppCtrl.requestPending = false; + return $uibModalInstance.close('Authenticator app disabled'); + }) + .catch(function (error) { + authAppCtrl.requestPending = false; + $scope.operationResult = Utils.buildErrorResult(error.data.error); + authAppCtrl.reset(); + }); + }; + } + + function ViewRecoveryCodesController($uibModalInstance, $uibModal, AuthenticatorAppService) { + var authAppCtrl = this; + authAppCtrl.populateRecoveryCodes = populateRecoveryCodes; + authAppCtrl.dismiss = dismiss; + authAppCtrl.resetRecoveryCodesConfirmation = resetRecoveryCodesConfirmation; + authAppCtrl.disabled = false; + + authAppCtrl.$onInit = function () { + populateRecoveryCodes(); + } + + function populateRecoveryCodes() { + AuthenticatorAppService.viewRecoveryCodes().then(function (response) { + authAppCtrl.recoveryCodes = response.data; + buildListElement(); + }); + } + + function buildListElement() { + var str = '
    '; + authAppCtrl.recoveryCodes.forEach(function (recoveryCode) { + str += '
  • ' + recoveryCode + '
  • '; + }); + str += '
'; + document.getElementById("recoveryCodes").innerHTML = str; + } + + function dismiss() { return $uibModalInstance.dismiss('Back'); } + + function resetRecoveryCodesConfirmation() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/recovery-codes-reset-confirm.html', + controller: 'ResetRecoveryCodesController', + controllerAs: 'authAppCtrl', + resolve: { user: function () { return self.user; } } + }); + + modalInstance.result.then(function () { + populateRecoveryCodes(); + }); + } + } + + function ResetRecoveryCodesController($uibModalInstance, Utils, AuthenticatorAppService) { + var authAppCtrl = this; + authAppCtrl.dismiss = dismiss; + authAppCtrl.resetRecoveryCodes = resetRecoveryCodes; + authAppCtrl.requestPending = false; + + function dismiss() { return $uibModalInstance.dismiss('Back'); } + + function resetRecoveryCodes() { + authAppCtrl.requestPending = true; + AuthenticatorAppService.resetRecoveryCodes() + .then(function () { + authAppCtrl.requestPending = false; + $uibModalInstance.close('Recovery codes updated'); + }) + .catch(function (error) { + authAppCtrl.requestPending = false; + $scope.operationResult = Utils.buildErrorResult(error.data); + }); + } + } + +})(); \ No newline at end of file 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..673d0f422 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js @@ -0,0 +1,108 @@ +/* + * 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; + userMfaCtrl.viewRecoveryCodes = viewRecoveryCodes; + + 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); + }); + } + + function viewRecoveryCodes() { + var modalInstance = $uibModal.open({ + templateUrl: '/resources/iam/apps/dashboard-app/templates/home/view-recovery-codes.html', + controller: 'ViewRecoveryCodesController', + 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'); + }; +} \ No newline at end of file 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..316253f01 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js @@ -0,0 +1,77 @@ +/* + * 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, + viewRecoveryCodes: viewRecoveryCodes, + resetRecoveryCodes: resetRecoveryCodes + }; + + 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 viewRecoveryCodes() { + + return $http.get('/iam/authenticator-app/recovery-code/get'); + } + + function resetRecoveryCodes() { + + return $http.put('/iam/authenticator-app/recovery-code/reset'); + } +} \ 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/utils.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js index bbe64d850..868d737f1 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, @@ -86,6 +87,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:')); 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..a37eca5d6 --- /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/editmfasettings.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html new file mode 100644 index 000000000..533dba222 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html @@ -0,0 +1,57 @@ + +
+ + + + + + +
\ No newline at end of file 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..d9e31113f --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html @@ -0,0 +1,51 @@ + +
+ + + + + + + +
\ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/recovery-codes-reset-confirm.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/recovery-codes-reset-confirm.html new file mode 100644 index 000000000..84f9dee85 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/recovery-codes-reset-confirm.html @@ -0,0 +1,32 @@ + + + + + + \ No newline at end of file diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/view-recovery-codes.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/view-recovery-codes.html new file mode 100644 index 000000000..ba38b4c92 --- /dev/null +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/view-recovery-codes.html @@ -0,0 +1,35 @@ + + + + + + \ No newline at end of file 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 28503cae3..3fac4ae58 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; @@ -140,6 +151,10 @@ margin-top: 2em; } +#verify-confirm { + margin-top: 2em +} + .reset-password-form { margin: 0 auto; max-width: 400px; @@ -320,10 +335,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; @@ -348,6 +373,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..4516febce --- /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() throws Exception { + 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/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..e7ec9ed96 --- /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,321 @@ +/** + * 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 it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.DISABLE_URL; +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 it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService; +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; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class AuthenticatorAppSettingsControllerTests extends MultiFactorTestSupport { + + private MockMvc mvc; + + @Autowired + private WebApplicationContext context; + + @MockBean + private IamAccountRepository accountRepository; + + @MockBean + private IamTotpMfaService totpMfaService; + + @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)); + + 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("secret"); + when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa); + + mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isOk()); + + // TODO called twice for some reason? + verify(accountRepository, times(2)).findByUsername(TEST_USERNAME); + verify(totpMfaService, times(1)).addTotpMfaSecret(account); + } + + @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(TEST_ACCOUNT); + totpMfa.setSecret("secret"); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(true); + when(totpMfaService.enableTotpMfa(account)).thenReturn(totpMfa); + + mvc.perform(post(ENABLE_URL).param("totp", 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 testEnableAuthenticatorAppIncorrectCode() throws Exception { + IamAccount account = cloneAccount(TEST_ACCOUNT); + String totp = "123456"; + + when(totpMfaService.verifyTotp(account, totp)).thenReturn(false); + + mvc.perform(post(ENABLE_URL).param("totp", 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("totp", 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("totp", 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("totp", 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("totp", 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("totp", 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("totp", totp)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testEnableAuthenticatorAppPreAuthenticationIsUnauthorized() throws Exception { + String totp = "123456"; + + mvc.perform(post(ENABLE_URL).param("totp", 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("totp", 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("totp", 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("totp", 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("totp", 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("totp", 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("totp", 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("totp", 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("totp", totp)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testDisableAuthenticatorAppPreAuthenticationIsUnauthorized() throws Exception { + String totp = "123456"; + + mvc.perform(post(DISABLE_URL).param("totp", totp)).andExpect(status().isUnauthorized()); + } +} 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..060c3c444 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java @@ -0,0 +1,131 @@ +/** + * 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 java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +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.persistence.model.IamTotpRecoveryCode; + +public class IamTotpMfaServiceTestSupport { + + 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_MFA_SECRET = "secret"; + + public static final String TOTP_RECOVERY_CODE_STRING_1 = "code-1"; + public static final String TOTP_RECOVERY_CODE_STRING_2 = "code-2"; + public static final String TOTP_RECOVERY_CODE_STRING_3 = "code-3"; + public static final String TOTP_RECOVERY_CODE_STRING_4 = "code-4"; + public static final String TOTP_RECOVERY_CODE_STRING_5 = "code-5"; + public static final String TOTP_RECOVERY_CODE_STRING_6 = "code-6"; + public static final String TOTP_RECOVERY_CODE_STRING_7 = "code-7"; + public static final String TOTP_RECOVERY_CODE_STRING_8 = "code-8"; + public static final String TOTP_RECOVERY_CODE_STRING_9 = "code-9"; + public static final String TOTP_RECOVERY_CODE_STRING_10 = "code-10"; + public static final String TOTP_RECOVERY_CODE_STRING_11 = "code-11"; + public static final String TOTP_RECOVERY_CODE_STRING_12 = "code-12"; + + protected final IamAccount TOTP_MFA_ACCOUNT; + protected final IamAuthority ROLE_USER_AUTHORITY; + + protected final IamTotpMfa TOTP_MFA; + + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_1; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_2; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_3; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_4; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_5; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_6; + + 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(TOTP_MFA_SECRET); + TOTP_MFA.setActive(true); + + TOTP_RECOVERY_CODE_1 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_2 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_3 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_4 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_5 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_6 = new IamTotpRecoveryCode(TOTP_MFA); + + TOTP_RECOVERY_CODE_1.setCode(TOTP_RECOVERY_CODE_STRING_1); + TOTP_RECOVERY_CODE_2.setCode(TOTP_RECOVERY_CODE_STRING_2); + TOTP_RECOVERY_CODE_3.setCode(TOTP_RECOVERY_CODE_STRING_3); + TOTP_RECOVERY_CODE_4.setCode(TOTP_RECOVERY_CODE_STRING_4); + TOTP_RECOVERY_CODE_5.setCode(TOTP_RECOVERY_CODE_STRING_5); + TOTP_RECOVERY_CODE_6.setCode(TOTP_RECOVERY_CODE_STRING_6); + + TOTP_MFA + .setRecoveryCodes(new HashSet<>(Arrays.asList(TOTP_RECOVERY_CODE_1, TOTP_RECOVERY_CODE_2, + TOTP_RECOVERY_CODE_3, TOTP_RECOVERY_CODE_4, TOTP_RECOVERY_CODE_5, TOTP_RECOVERY_CODE_6))); + + 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()); + + Set newCodes = new HashSet<>(); + for (IamTotpRecoveryCode recoveryCode : totpMfa.getRecoveryCodes()) { + IamTotpRecoveryCode newCode = new IamTotpRecoveryCode(newTotpMfa); + newCode.setCode(recoveryCode.getCode()); + newCodes.add(newCode); + } + newTotpMfa.setRecoveryCodes(newCodes); + + newTotpMfa.touch(); + + return newTotpMfa; + } +} 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..2ce503607 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java @@ -0,0 +1,246 @@ +/** + * 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.hamcrest.Matchers.not; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +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 java.util.Set; + +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.recovery.RecoveryCodeGenerator; +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.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.model.IamTotpRecoveryCode; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; + +@RunWith(MockitoJUnitRunner.class) +public class IamTotpMfaServiceTests extends IamTotpMfaServiceTestSupport { + + private IamTotpMfaService service; + + @Mock + private IamTotpMfaRepository repository; + + @Mock + private SecretGenerator secretGenerator; + + @Mock + private RecoveryCodeGenerator recoveryCodeGenerator; + + @Mock + private IamAccountService iamAccountService; + + @Mock + private CodeVerifier codeVerifier; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Captor + private ArgumentCaptor eventCaptor; + + @Before + public void setup() { + 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); + + String[] testArray = {TOTP_RECOVERY_CODE_STRING_7, TOTP_RECOVERY_CODE_STRING_8, + TOTP_RECOVERY_CODE_STRING_9, TOTP_RECOVERY_CODE_STRING_10, TOTP_RECOVERY_CODE_STRING_11, + TOTP_RECOVERY_CODE_STRING_12}; + when(recoveryCodeGenerator.generateCodes(anyInt())).thenReturn(testArray); + + service = new DefaultIamTotpMfaService(iamAccountService, repository, secretGenerator, + recoveryCodeGenerator, codeVerifier, eventPublisher); + } + + @After + public void tearDown() { + reset(secretGenerator, repository, iamAccountService, codeVerifier, recoveryCodeGenerator); + } + + @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(); + verify(recoveryCodeGenerator, times(1)).generateCodes(anyInt()); + + 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 testAddsMfaRecoveryCodesToAccount() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + Set originalCodes = totpMfa.getRecoveryCodes(); + + try { + totpMfa = service.addTotpMfaRecoveryCodes(account); + } catch (MfaSecretNotFoundException e) { + assertThat(e.getMessage(), equalTo("No multi-factor secret is attached to this account")); + throw e; + } + + Set newCodes = totpMfa.getRecoveryCodes(); + assertThat(originalCodes.toArray(), not(equalTo(newCodes.toArray()))); + } + + @Test(expected = MfaSecretNotFoundException.class) + public void testAddsMfaRecoveryCode_whenNoMfaSecretAssignedFails() { + when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty()); + + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + + try { + service.addTotpMfaRecoveryCodes(account); + } catch (MfaSecretNotFoundException e) { + assertThat(e.getMessage(), equalTo("No multi-factor secret is attached to this account")); + throw e; + } + } + + @Test + public void testEnablesTotpMfa() { + IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT); + IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA); + totpMfa.setSecret("secret"); + 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; + } + } +} 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..4995dbe01 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.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.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.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.WithMockPreAuthenticatedUser; +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(TOTP_MFA_ACCOUNT)); + when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenAnswer(i -> i.getArguments()[0]); + + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @Test + @WithMockPreAuthenticatedUser + public void testGetVerifyMfaView() throws Exception { + mvc.perform(get(MFA_VERIFY_URL)) + .andExpect(status().isOk()) + .andExpect(model().attributeExists("factors")); + + verify(accountRepository, times(1)).findByUsername(TEST_USERNAME); + verify(totpMfaRepository, times(1)).findByAccount(TOTP_MFA_ACCOUNT); + } + + @Test + @WithAnonymousUser + 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/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..8550a82d5 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java @@ -0,0 +1,202 @@ +/** + * 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 java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamTotpMfa; +import it.infn.mw.iam.persistence.model.IamTotpRecoveryCode; + +public class MultiFactorTestSupport { + 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"; + public static final String TOTP_MFA_SECRET = "secret"; + public static final String TOTP_RECOVERY_CODE_STRING_1 = "code-1"; + public static final String TOTP_RECOVERY_CODE_STRING_2 = "code-2"; + public static final String TOTP_RECOVERY_CODE_STRING_3 = "code-3"; + public static final String TOTP_RECOVERY_CODE_STRING_4 = "code-4"; + public static final String TOTP_RECOVERY_CODE_STRING_5 = "code-5"; + public static final String TOTP_RECOVERY_CODE_STRING_6 = "code-6"; + public static final String TOTP_RECOVERY_CODE_STRING_7 = "code-7"; + public static final String TOTP_RECOVERY_CODE_STRING_8 = "code-8"; + public static final String TOTP_RECOVERY_CODE_STRING_9 = "code-9"; + public static final String TOTP_RECOVERY_CODE_STRING_10 = "code-10"; + public static final String TOTP_RECOVERY_CODE_STRING_11 = "code-11"; + public static final String TOTP_RECOVERY_CODE_STRING_12 = "code-12"; + + protected final IamAccount TEST_ACCOUNT; + protected final IamAccount TOTP_MFA_ACCOUNT; + protected final IamTotpMfa TOTP_MFA; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_1; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_2; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_3; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_4; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_5; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_6; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_7; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_8; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_9; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_10; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_11; + protected final IamTotpRecoveryCode TOTP_RECOVERY_CODE_12; + + protected final Set RECOVERY_CODE_SET_FIRST; + protected final Set RECOVERY_CODE_SET_SECOND; + + 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(TOTP_MFA_SECRET); + TOTP_MFA.setActive(true); + TOTP_MFA.touch(); + + TOTP_RECOVERY_CODE_1 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_2 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_3 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_4 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_5 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_6 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_7 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_8 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_9 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_10 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_11 = new IamTotpRecoveryCode(TOTP_MFA); + TOTP_RECOVERY_CODE_12 = new IamTotpRecoveryCode(TOTP_MFA); + + TOTP_RECOVERY_CODE_1.setCode(TOTP_RECOVERY_CODE_STRING_1); + TOTP_RECOVERY_CODE_2.setCode(TOTP_RECOVERY_CODE_STRING_2); + TOTP_RECOVERY_CODE_3.setCode(TOTP_RECOVERY_CODE_STRING_3); + TOTP_RECOVERY_CODE_4.setCode(TOTP_RECOVERY_CODE_STRING_4); + TOTP_RECOVERY_CODE_5.setCode(TOTP_RECOVERY_CODE_STRING_5); + TOTP_RECOVERY_CODE_6.setCode(TOTP_RECOVERY_CODE_STRING_6); + TOTP_RECOVERY_CODE_7.setCode(TOTP_RECOVERY_CODE_STRING_7); + TOTP_RECOVERY_CODE_8.setCode(TOTP_RECOVERY_CODE_STRING_8); + TOTP_RECOVERY_CODE_9.setCode(TOTP_RECOVERY_CODE_STRING_9); + TOTP_RECOVERY_CODE_10.setCode(TOTP_RECOVERY_CODE_STRING_10); + TOTP_RECOVERY_CODE_11.setCode(TOTP_RECOVERY_CODE_STRING_11); + TOTP_RECOVERY_CODE_12.setCode(TOTP_RECOVERY_CODE_STRING_12); + + RECOVERY_CODE_SET_FIRST = new HashSet<>( + Arrays.asList(TOTP_RECOVERY_CODE_1, TOTP_RECOVERY_CODE_2, TOTP_RECOVERY_CODE_3, + TOTP_RECOVERY_CODE_4, TOTP_RECOVERY_CODE_5, TOTP_RECOVERY_CODE_6)); + RECOVERY_CODE_SET_SECOND = new HashSet<>( + Arrays.asList(TOTP_RECOVERY_CODE_7, TOTP_RECOVERY_CODE_8, TOTP_RECOVERY_CODE_9, + TOTP_RECOVERY_CODE_10, TOTP_RECOVERY_CODE_11, TOTP_RECOVERY_CODE_12)); + + TOTP_MFA.setRecoveryCodes(RECOVERY_CODE_SET_FIRST); + } + + 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(TOTP_MFA_SECRET); + TOTP_MFA.setActive(true); + TOTP_MFA.touch(); + + TOTP_RECOVERY_CODE_1.setCode(TOTP_RECOVERY_CODE_STRING_1); + TOTP_RECOVERY_CODE_2.setCode(TOTP_RECOVERY_CODE_STRING_2); + TOTP_RECOVERY_CODE_3.setCode(TOTP_RECOVERY_CODE_STRING_3); + TOTP_RECOVERY_CODE_4.setCode(TOTP_RECOVERY_CODE_STRING_4); + TOTP_RECOVERY_CODE_5.setCode(TOTP_RECOVERY_CODE_STRING_5); + TOTP_RECOVERY_CODE_6.setCode(TOTP_RECOVERY_CODE_STRING_6); + TOTP_RECOVERY_CODE_7.setCode(TOTP_RECOVERY_CODE_STRING_7); + TOTP_RECOVERY_CODE_8.setCode(TOTP_RECOVERY_CODE_STRING_8); + TOTP_RECOVERY_CODE_9.setCode(TOTP_RECOVERY_CODE_STRING_9); + TOTP_RECOVERY_CODE_10.setCode(TOTP_RECOVERY_CODE_STRING_10); + TOTP_RECOVERY_CODE_11.setCode(TOTP_RECOVERY_CODE_STRING_11); + TOTP_RECOVERY_CODE_12.setCode(TOTP_RECOVERY_CODE_STRING_12); + + TOTP_MFA.setRecoveryCodes(RECOVERY_CODE_SET_FIRST); + } + + 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()); + + Set newCodes = new HashSet<>(); + for (IamTotpRecoveryCode recoveryCode : totpMfa.getRecoveryCodes()) { + IamTotpRecoveryCode newCode = new IamTotpRecoveryCode(newTotpMfa); + newCode.setCode(recoveryCode.getCode()); + newCodes.add(newCode); + } + newTotpMfa.setRecoveryCodes(newCodes); + + newTotpMfa.touch(); + + return newTotpMfa; + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/RecoveryCodeManagementControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/RecoveryCodeManagementControllerTests.java new file mode 100644 index 000000000..7d03ee299 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/RecoveryCodeManagementControllerTests.java @@ -0,0 +1,208 @@ +/** + * 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.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_GET_URL; +import static it.infn.mw.iam.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_RESET_URL; +import static it.infn.mw.iam.authn.multi_factor_authentication.authenticator_app.RecoveryCodeManagementController.RECOVERY_CODE_VIEW_URL; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +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 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.request.MockMvcRequestBuilders.put; +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.ArrayList; +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.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.AuthenticationException; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import dev.samstevens.totp.recovery.RecoveryCodeGenerator; +import it.infn.mw.iam.api.account.AccountUtils; +import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpRecoveryCodeResetService; +import it.infn.mw.iam.persistence.model.IamTotpRecoveryCode; +import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository; +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; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +public class RecoveryCodeManagementControllerTests extends MultiFactorTestSupport { + + private MockMvc mvc; + + @Autowired + private WebApplicationContext context; + + @MockBean + private IamTotpMfaRepository totpMfaRepository; + + @MockBean + private AccountUtils accountUtils; + + @MockBean + private IamTotpRecoveryCodeResetService service; + + @MockBean + private ApplicationEventPublisher eventPublisher; + + @MockBean + private RecoveryCodeGenerator generator; + + @Captor + private ArgumentCaptor authEventCaptor; + + @Captor + private ArgumentCaptor authExceptionCaptor; + + @Before + public void setup() { + when(accountUtils.getAuthenticatedUserAccount()).thenReturn(Optional.of(TOTP_MFA_ACCOUNT)); + when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA)); + when(service.resetRecoveryCodes(TOTP_MFA_ACCOUNT)).thenAnswer(i -> i.getArguments()[0]); + + mvc = + MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build(); + } + + @After + public void tearDown() { + reset(accountUtils); + reset(service); + reset(eventPublisher); + + resetTestAccount(); + } + + @Test + @WithMockMfaUser + public void testGetResetView() throws Exception { + mvc.perform(get(RECOVERY_CODE_RESET_URL)).andExpect(status().isOk()); + } + + @Test + @WithAnonymousUser + public void testGetResetViewNoAuthenticationFails() throws Exception { + mvc.perform(get(RECOVERY_CODE_RESET_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testGetResetViewWithPreAuthenticationFails() throws Exception { + mvc.perform(get(RECOVERY_CODE_RESET_URL)).andExpect(status().isUnauthorized()); + } + + // TODO test getResetView with user that doesn't have MFA enabled. Currently, I don't think the + // user is forbidden in this case + + @Test + @WithMockMfaUser + public void testPutResetAddsNewCodes() throws Exception { + mvc.perform(put(RECOVERY_CODE_RESET_URL)).andExpect(status().isOk()); + verify(service, times(1)).resetRecoveryCodes(TOTP_MFA_ACCOUNT); + } + + @Test + @WithAnonymousUser + public void testPutResetNoAuthenticationFails() throws Exception { + mvc.perform(put(RECOVERY_CODE_RESET_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testPutResetWithPreAuthenticationFails() throws Exception { + mvc.perform(put(RECOVERY_CODE_RESET_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockMfaUser + public void testViewRecoveryCodes() throws Exception { + mvc.perform(get(RECOVERY_CODE_VIEW_URL)) + .andExpect(status().isOk()) + .andExpect(model().attributeExists("recoveryCodes")); + } + + @Test + @WithAnonymousUser + public void testViewRecoveryCodesNoAuthenticationFails() throws Exception { + mvc.perform(get(RECOVERY_CODE_VIEW_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testViewRecoveryCodesWithPreAuthenticationFails() throws Exception { + mvc.perform(get(RECOVERY_CODE_VIEW_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockMfaUser + public void testGetRecoveryCodes() throws Exception { + MvcResult result = + mvc.perform(get(RECOVERY_CODE_GET_URL)).andExpect(status().isOk()).andReturn(); + + String content = result.getResponse().getContentAsString(); + content = content.substring(1, content.length() - 1); + String[] arr = content.split(","); + + String[] originalCodes = new String[RECOVERY_CODE_SET_FIRST.size()]; + List recoveryCodes = new ArrayList<>(RECOVERY_CODE_SET_FIRST); + for (int i = 0; i < recoveryCodes.size(); i++) { + originalCodes[i] = recoveryCodes.get(i).getCode(); + + // This is here because the string.split() method adds backslashed quotes around the separated + // strings. So this is a hacky method to remove them to allow for the comparison to succeed. + arr[i] = arr[i].substring(1, arr[i].length() - 1); + } + assertThat(originalCodes, equalTo(arr)); + } + + @Test + @WithAnonymousUser + public void testGetRecoveryCodesNoAuthenticationFails() throws Exception { + mvc.perform(get(RECOVERY_CODE_GET_URL)).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockPreAuthenticatedUser + public void testGetRecoveryCodesWithPreAuthenticationFails() throws Exception { + mvc.perform(get(RECOVERY_CODE_GET_URL)).andExpect(status().isUnauthorized()); + } +} 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 fded5a4ad..4e3b2c850 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 @@ -41,6 +41,8 @@ import java.util.Date; import java.util.Optional; +import com.google.common.collect.Sets; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -56,8 +58,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.core.time.TimeProvider; import it.infn.mw.iam.core.user.DefaultIamAccountService; @@ -123,6 +123,7 @@ public void setup() { when(accountRepo.findByEmail(anyString())).thenReturn(Optional.empty()); when(accountRepo.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT)); when(accountRepo.findByEmail(TEST_EMAIL)).thenReturn(Optional.of(TEST_ACCOUNT)); + when(accountRepo.save(any(IamAccount.class))).thenAnswer(i -> i.getArguments()[0]); when(authoritiesRepo.findByAuthority(anyString())).thenReturn(Optional.empty()); when(authoritiesRepo.findByAuthority("ROLE_USER")).thenReturn(Optional.of(ROLE_USER_AUTHORITY)); when(passwordEncoder.encode(any())).thenReturn(PASSWORD); @@ -842,5 +843,4 @@ public void testSetEndTimeWorksForNonNullDate() { assertThat(e.getPreviousEndTime(), nullValue()); assertThat(e.getAccount().getEndTime(), is(newEndTime)); } - } 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/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/src/main/resources/db/migration/h2/V70___totp_mfa.sql b/iam-persistence/src/main/resources/db/migration/h2/V70___totp_mfa.sql index 3496048cd..94f0cc2dd 100644 --- a/iam-persistence/src/main/resources/db/migration/h2/V70___totp_mfa.sql +++ b/iam-persistence/src/main/resources/db/migration/h2/V70___totp_mfa.sql @@ -6,4 +6,4 @@ CREATE TABLE iam_totp_recovery_code (ID BIGINT IDENTITY NOT NULL, code VARCHAR(2 ALTER TABLE iam_totp_mfa ADD CONSTRAINT FK_iam_totp_mfa_account_id FOREIGN KEY (ACCOUNT_ID) REFERENCES iam_account (ID); -ALTER TABLE iam_totp_recovery_code ADD CONSTRAINT FK_iam_totp_recovery_code_totp_mfa_id FOREIGN KEY (totp_mfa_id) REFERENCES iam_totp_mfa (ID); \ No newline at end of file +ALTER TABLE iam_totp_recovery_code ADD CONSTRAINT FK_iam_totp_recovery_code_totp_mfa_id FOREIGN KEY (totp_mfa_id) REFERENCES iam_totp_mfa (ID); diff --git a/iam-persistence/src/main/resources/db/migration/h2/V71___add_pre_authenticated_authority.sql b/iam-persistence/src/main/resources/db/migration/h2/V71___add_pre_authenticated_authority.sql index 54a86d5cd..efb0eed8b 100644 --- a/iam-persistence/src/main/resources/db/migration/h2/V71___add_pre_authenticated_authority.sql +++ b/iam-persistence/src/main/resources/db/migration/h2/V71___add_pre_authenticated_authority.sql @@ -1,2 +1,2 @@ INSERT INTO iam_authority(AUTH) VALUES -('ROLE_PRE_AUTHENTICATED'); \ No newline at end of file +('ROLE_PRE_AUTHENTICATED'); diff --git a/iam-persistence/src/main/resources/db/migration/mysql/V70___totp_mfa.sql b/iam-persistence/src/main/resources/db/migration/mysql/V70___totp_mfa.sql index fdaa23f94..cad0a9082 100644 --- a/iam-persistence/src/main/resources/db/migration/mysql/V70___totp_mfa.sql +++ b/iam-persistence/src/main/resources/db/migration/mysql/V70___totp_mfa.sql @@ -6,4 +6,4 @@ CREATE TABLE iam_totp_recovery_code (ID BIGINT AUTO_INCREMENT NOT NULL, code VAR ALTER TABLE iam_totp_mfa ADD CONSTRAINT FK_iam_totp_mfa_account_id FOREIGN KEY (ACCOUNT_ID) REFERENCES iam_account (ID); -ALTER TABLE iam_totp_recovery_code ADD CONSTRAINT FK_iam_totp_recovery_code_totp_mfa_id FOREIGN KEY (totp_mfa_id) REFERENCES iam_totp_mfa (ID); \ No newline at end of file +ALTER TABLE iam_totp_recovery_code ADD CONSTRAINT FK_iam_totp_recovery_code_totp_mfa_id FOREIGN KEY (totp_mfa_id) REFERENCES iam_totp_mfa (ID); diff --git a/iam-test-client/.gitignore b/iam-test-client/.gitignore index 9ed481cb6..c75fc1032 100644 --- a/iam-test-client/.gitignore +++ b/iam-test-client/.gitignore @@ -1,2 +1,2 @@ /.apt_generated/ -/target/ +/target/ \ No newline at end of file diff --git a/pom.xml b/pom.xml index 18e83817a..121124355 100644 --- a/pom.xml +++ b/pom.xml @@ -52,6 +52,7 @@ 1.3.6.cnaf-20230914 2.5.2.RELEASE + 6.1.5 3.3.2 1.0.10.RELEASE @@ -276,6 +277,12 @@ ${jaxb-runtime.version} + + org.springframework.security + spring-security-oauth2-client + ${spring-security-oauth2-client.version} + +