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.securityspring-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 extends GrantedAuthority> 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
+ *
+ * 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"%>
+
+
+
+
+
+
+
+
+
+
+
+
\ 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"%>
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
+--%>
+
+
+
+
+
+
+
+
+
+
\ 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 = '
';
+ 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 @@
+
+
+
Reset recovery codes
+
+
+
+
+
+
Are you sure you want to reset your recovery codes?
+
This action can't be undone.
+
+
+
\ 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 @@
+
+
+
Authenticator app recovery codes
+
+
+
+
+
+
Here are your multi-factor recovery codes.
+
It is important you write these down and store them in a safe place.
+
They will help you access your account if you lose access to your authenticator app.
+
+
+
+
+
\ 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-202309142.5.2.RELEASE
+ 6.1.53.3.21.0.10.RELEASE
@@ -276,6 +277,12 @@
${jaxb-runtime.version}
+
+ org.springframework.security
+ spring-security-oauth2-client
+ ${spring-security-oauth2-client.version}
+
+