diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml
index a8136225f..db2809c28 100644
--- a/.github/workflows/sonar.yaml
+++ b/.github/workflows/sonar.yaml
@@ -4,6 +4,7 @@ on:
push:
branches:
- develop
+
pull_request:
types: [opened, edited, reopened, synchronize]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23e340986..6b920e2c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,44 @@
# Changelog
+## 1.11.0 (2024-12-19)
+
+### What's Changed
+
+* Add confirmation before rotate client secret by @SteDev2 in https://github.com/indigo-iam/iam/pull/875
+* Fix account mapping in VOMS AA by @rmiccoli in https://github.com/indigo-iam/iam/pull/872
+* Add POST endpoint for registration requests confirmation by @enricovianello in https://github.com/indigo-iam/iam/pull/881
+* Fix CERN lifecycle handler by @enricovianello in https://github.com/indigo-iam/iam/pull/871, https://github.com/indigo-iam/iam/pull/896
+* Grant admin scopes to admin-approved clients only by @rmiccoli in https://github.com/indigo-iam/iam/commit/6bbaccd4e85cc1dc1659ea10fa31dd5307b2dc62
+* Client-credentials flow won't create a refresh token by @rmiccoli in https://github.com/indigo-iam/OpenID-Connect-Java-Spring-Server/pull/22
+* Redirect to login page when signing AUP by @federicaagostini in https://github.com/indigo-iam/iam/commit/5acde91cd333d139991e2ba1ee6d5fe062d986a0
+* Fix missing update of matchingPolicy by @garaimanoj in https://github.com/indigo-iam/iam/commit/f15ef57b1e11f3f08e1b5cb2462520efd3c1108d
+* Find account by certificate sub and iss in VOMS AA by @rmiccoli in https://github.com/indigo-iam/iam/pull/897
+* Exclude IAM optional groups from VOMS AC by @rmiccoli in https://github.com/indigo-iam/iam/pull/894
+* Find account by certificate sub and iss in VOMS AA by @rmiccoli in https://github.com/indigo-iam/iam/pull/897
+* Prevent the issue of broken SAML login flow by @DonaldChung-HK in https://github.com/indigo-iam/iam/pull/885
+
+### Added
+
+* (_Experimental_*) Implement MFA by @sam-glendenning, @rmiccoli, @garaimanoj, @Sae126V in https://github.com/indigo-iam/iam/pull/733
+
+(*) This initial release featuring Multi-Factor Authentication is experimental and will be enhanced and expanded with new features in future releases, based also on user feedback.
+
+### MFA experimental feature summary
+
+* Each authenticated user can enable/disable MFA through a button in their homepage
+ * user will use an authenticator, as it is required to generate the time-based one-time passwords (TOTPs) necessary for authentication
+* If issues arise with the authenticator, the IAM administrator can disable MFA for a user
+* Authenticator working for local authentication only
+ * integration with X.509 certificates and external providers not yet supported
+* Encryption and decryption of MFA secrets
+
+#### Configuration
+
+The `mfa` Spring profile is used to enable MFA functionality. By default, MFA is disabled for all users.
+
## 1.10.2 (2024-09-30)
-## What's Changed
+### What's Changed
* Add devcontainer configuration https://github.com/indigo-iam/iam/pull/835
* Track refresh tokens in access token AUDIT logs https://github.com/indigo-iam/iam/pull/838
@@ -10,7 +46,7 @@
## 1.10.1 (2024-08-22)
-## What's Fixed
+### What's Fixed
* Fix repeated suspensions https://github.com/indigo-iam/iam/pull/831
* Fix typo in AUDIT log for suspended accounts https://github.com/indigo-iam/iam/pull/832
@@ -259,7 +295,6 @@ fixes several bugs for the IAM login service.
## 1.7.2 (2021-12-03)
This release provides a single dependency change for the IAM login service
-application.
### Added
@@ -271,6 +306,10 @@ This release provides changes and bug fixes to the IAM test client application.
### Added
+This release provides changes and bug fixes to the IAM test client application.
+
+### Added
+
- The IAM test client application, in its default configuration, no longer
exposes tokens, but only the claims contained in tokens. It's possible to
revert to the previous behavior by setting the `IAM_CLIENT_HIDE_TOKENS=false`
diff --git a/Jenkinsfile b/Jenkinsfile
index 185e85069..2ad6da820 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -90,7 +90,7 @@ pipeline {
post {
always {
script {
- maybeArchiveJUnitReports()
+ maybeArchiveJUnitReportsWithJacoco()
}
}
}
diff --git a/compose/custom-nginx/iam.conf b/compose/custom-nginx/iam.conf
index 86509e0a5..f8dade3c1 100644
--- a/compose/custom-nginx/iam.conf
+++ b/compose/custom-nginx/iam.conf
@@ -38,6 +38,8 @@ server {
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Protocol $ssl_protocol;
proxy_set_header X-SSL-Server-Name $ssl_server_name;
+
+ proxy_cookie_flags ~ secure samesite=none;
}
location /iam-test-client {
diff --git a/iam-common/pom.xml b/iam-common/pom.xml
index 9b73ce839..673dbffb8 100644
--- a/iam-common/pom.xml
+++ b/iam-common/pom.xml
@@ -5,7 +5,7 @@
it.infn.mw.iam-parentiam-parent
- 1.10.2
+ 1.11.0it.infn.mw.iam-common
diff --git a/iam-login-service/docker/Dockerfile.prod b/iam-login-service/docker/Dockerfile.prod
index baa7a1293..91d592071 100644
--- a/iam-login-service/docker/Dockerfile.prod
+++ b/iam-login-service/docker/Dockerfile.prod
@@ -1,4 +1,5 @@
FROM eclipse-temurin:17 as builder
+
RUN mkdir /indigo-iam
WORKDIR /indigo-iam
COPY iam-login-service.war /indigo-iam/
diff --git a/iam-login-service/pom.xml b/iam-login-service/pom.xml
index df60a54c7..39d51afa2 100644
--- a/iam-login-service/pom.xml
+++ b/iam-login-service/pom.xml
@@ -22,7 +22,7 @@
it.infn.mw.iam-parentiam-parent
- 1.10.2
+ 1.11.0it.infn.mw.iam-login-service
@@ -92,7 +92,6 @@
org.springframework.bootspring-boot-starter-data-redis
-
@@ -221,6 +220,11 @@
spring-security-oauth2
+
+ org.springframework.security
+ spring-security-oauth2-client
+
+
org.springframework.securityspring-security-test
@@ -406,6 +410,13 @@
jaxb-runtime
+
+
+ dev.samstevens.totp
+ totp
+ 1.7.1
+
+
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java
index 8d18c6d7f..9d6d134f6 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/AccountUtils.java
@@ -15,11 +15,8 @@
*/
package it.infn.mw.iam.api.account;
-import static java.util.Objects.isNull;
-
import java.util.Optional;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -27,6 +24,7 @@
import org.springframework.stereotype.Component;
import it.infn.mw.iam.authn.util.Authorities;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
@@ -35,13 +33,12 @@
public class AccountUtils {
IamAccountRepository accountRepo;
- @Autowired
public AccountUtils(IamAccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
public boolean isRegisteredUser(Authentication auth) {
- if (auth == null || auth.getAuthorities() == null) {
+ if (auth == null || auth.getAuthorities().isEmpty()) {
return false;
}
@@ -49,13 +46,21 @@ public boolean isRegisteredUser(Authentication auth) {
}
public boolean isAdmin(Authentication auth) {
- if (auth == null || auth.getAuthorities() == null) {
+ if (auth == null || auth.getAuthorities().isEmpty()) {
return false;
}
return auth.getAuthorities().contains(Authorities.ROLE_ADMIN);
}
+ public boolean isPreAuthenticated(Authentication auth) {
+ if (auth == null || auth.getAuthorities().isEmpty()) {
+ return false;
+ }
+
+ return auth.getAuthorities().contains(Authorities.ROLE_PRE_AUTHENTICATED);
+ }
+
public boolean isAuthenticated() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
@@ -63,7 +68,8 @@ public boolean isAuthenticated() {
}
public boolean isAuthenticated(Authentication auth) {
- return !(isNull(auth) || auth instanceof AnonymousAuthenticationToken);
+ return auth != null && !(auth instanceof AnonymousAuthenticationToken)
+ && (!(auth instanceof ExtendedAuthenticationToken) || auth.isAuthenticated());
}
public Optional getAuthenticatedUserAccount(Authentication authn) {
@@ -72,9 +78,8 @@ public Optional getAuthenticatedUserAccount(Authentication authn) {
}
Authentication userAuthn = authn;
-
- if (authn instanceof OAuth2Authentication) {
- OAuth2Authentication oauth = (OAuth2Authentication) authn;
+
+ if (authn instanceof OAuth2Authentication oauth) {
if (oauth.getUserAuthentication() == null) {
return Optional.empty();
}
@@ -86,13 +91,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/attributes/AccountAttributesController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/attributes/AccountAttributesController.java
index b5adc6604..f663ec9d2 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/attributes/AccountAttributesController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/attributes/AccountAttributesController.java
@@ -18,22 +18,19 @@
import static it.infn.mw.iam.api.utils.ValidationErrorUtils.stringifyValidationError;
import static java.lang.String.format;
import static org.springframework.http.HttpStatus.NO_CONTENT;
-import static org.springframework.web.bind.annotation.RequestMethod.DELETE;
-import static org.springframework.web.bind.annotation.RequestMethod.PUT;
import java.util.List;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
-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 org.springframework.web.bind.annotation.RestController;
@@ -57,7 +54,6 @@ public class AccountAttributesController {
final IamAccountService accountService;
final AttributeDTOConverter converter;
- @Autowired
public AccountAttributesController(IamAccountService accountService,
AttributeDTOConverter converter) {
this.converter = converter;
@@ -71,7 +67,7 @@ private void handleValidationError(BindingResult result) {
}
}
- @RequestMapping(value = "/iam/account/{id}/attributes", method = RequestMethod.GET)
+ @GetMapping(value = "/iam/account/{id}/attributes")
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.isUser(#id) or #iam.hasAnyDashboardRole('ROLE_ADMIN', 'ROLE_GM')")
public List getAttributes(@PathVariable String id) {
@@ -84,7 +80,7 @@ public List getAttributes(@PathVariable String id) {
return results;
}
- @RequestMapping(value = "/iam/account/{id}/attributes", method = PUT)
+ @PutMapping(value = "/iam/account/{id}/attributes")
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
public void setAttribute(@PathVariable String id, @RequestBody @Validated AttributeDTO attribute,
final BindingResult validationResult) {
@@ -98,7 +94,7 @@ public void setAttribute(@PathVariable String id, @RequestBody @Validated Attrib
accountService.setAttribute(account, attr);
}
- @RequestMapping(value = "/iam/account/{id}/attributes", method = DELETE)
+ @DeleteMapping(value = "/iam/account/{id}/attributes")
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
@ResponseStatus(value = NO_CONTENT)
public void deleteAttribute(@PathVariable String id, @Validated AttributeDTO attribute,
@@ -114,14 +110,12 @@ public void deleteAttribute(@PathVariable String id, @Validated AttributeDTO att
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
@ExceptionHandler(InvalidAttributeError.class)
- @ResponseBody
public ErrorDTO handleValidationError(InvalidAttributeError e) {
return ErrorDTO.fromString(e.getMessage());
}
@ResponseStatus(code = HttpStatus.NOT_FOUND)
@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/api/account/group_manager/AccountGroupManagerController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/group_manager/AccountGroupManagerController.java
index b5682e58a..e63578b13 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/group_manager/AccountGroupManagerController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/group_manager/AccountGroupManagerController.java
@@ -16,17 +16,16 @@
package it.infn.mw.iam.api.account.group_manager;
import java.util.List;
-import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -50,7 +49,6 @@ public class AccountGroupManagerController {
final IamGroupRepository groupRepository;
final UserConverter userConverter;
- @Autowired
public AccountGroupManagerController(AccountGroupManagerService service,
IamAccountRepository accountRepo, IamGroupRepository groupRepository,
UserConverter userConverter) {
@@ -60,9 +58,7 @@ public AccountGroupManagerController(AccountGroupManagerService service,
this.userConverter = userConverter;
}
-
-
- @RequestMapping(value = "/iam/account/{accountId}/managed-groups", method = RequestMethod.GET)
+ @GetMapping(value = "/iam/account/{accountId}/managed-groups")
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasDashboardRole('ROLE_ADMIN') or #iam.isUser(#accountId)")
public AccountManagedGroupsDTO getAccountManagedGroupsInformation(
@PathVariable String accountId) {
@@ -72,8 +68,7 @@ public AccountManagedGroupsDTO getAccountManagedGroupsInformation(
return service.getManagedGroupInfoForAccount(account);
}
- @RequestMapping(value = "/iam/account/{accountId}/managed-groups/{groupId}",
- method = RequestMethod.POST)
+ @PostMapping(value = "/iam/account/{accountId}/managed-groups/{groupId}")
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
@ResponseStatus(value = HttpStatus.CREATED)
public void addManagedGroupToAccount(@PathVariable String accountId,
@@ -88,8 +83,7 @@ public void addManagedGroupToAccount(@PathVariable String accountId,
service.addManagedGroupForAccount(account, group);
}
- @RequestMapping(value = "/iam/account/{accountId}/managed-groups/{groupId}",
- method = RequestMethod.DELETE)
+ @DeleteMapping(value = "/iam/account/{accountId}/managed-groups/{groupId}")
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
public void removeManagedGroupFromAccount(@PathVariable String accountId,
@@ -104,7 +98,7 @@ public void removeManagedGroupFromAccount(@PathVariable String accountId,
service.removeManagedGroupForAccount(account, group);
}
- @RequestMapping(value = "/iam/group/{groupId}/group-managers", method=RequestMethod.GET)
+ @GetMapping(value = "/iam/group/{groupId}/group-managers")
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasDashboardRole('ROLE_ADMIN') or #iam.isGroupManager(#groupId)")
public List getGroupManagersForGroup(@PathVariable String groupId) {
IamGroup group = groupRepository.findByUuid(groupId)
@@ -113,7 +107,7 @@ public List getGroupManagersForGroup(@PathVariable String groupId) {
return service.getGroupManagersForGroup(group)
.stream()
.map(userConverter::dtoFromEntity)
- .collect(Collectors.toList());
+ .toList();
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/labels/AccountLabelsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/labels/AccountLabelsController.java
index 70a3dc554..63e5efb84 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/labels/AccountLabelsController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/labels/AccountLabelsController.java
@@ -18,23 +18,21 @@
import static it.infn.mw.iam.api.utils.ValidationErrorUtils.stringifyValidationError;
import static java.lang.String.format;
import static org.springframework.http.HttpStatus.NO_CONTENT;
-import static org.springframework.web.bind.annotation.RequestMethod.DELETE;
-import static org.springframework.web.bind.annotation.RequestMethod.GET;
-import static org.springframework.web.bind.annotation.RequestMethod.PUT;
import java.util.List;
import java.util.function.Supplier;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -58,7 +56,6 @@ public class AccountLabelsController {
final IamAccountService service;
final LabelDTOConverter converter;
- @Autowired
public AccountLabelsController(IamAccountService service, LabelDTOConverter converter) {
this.service = service;
this.converter = converter;
@@ -74,7 +71,7 @@ private void handleValidationError(BindingResult result) {
}
}
- @RequestMapping(method = GET)
+ @GetMapping
@PreAuthorize("#iam.hasScope('iam:admin.read') or #iam.hasAnyDashboardRole('ROLE_ADMIN', 'ROLE_GM') or #iam.isUser(#id)")
public List getLabels(@PathVariable String id) {
@@ -87,7 +84,7 @@ public List getLabels(@PathVariable String id) {
return results;
}
- @RequestMapping(method = PUT)
+ @PutMapping
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
public void setLabel(@PathVariable String id, @RequestBody @Validated LabelDTO label,
BindingResult validationResult) {
@@ -97,7 +94,7 @@ public void setLabel(@PathVariable String id, @RequestBody @Validated LabelDTO l
service.addLabel(account, converter.entityFromDto(label));
}
- @RequestMapping(method = DELETE)
+ @DeleteMapping
@PreAuthorize("#iam.hasScope('iam:admin.write') or #iam.hasDashboardRole('ROLE_ADMIN')")
@ResponseStatus(NO_CONTENT)
public void deleteLabel(@PathVariable String id, @Validated LabelDTO label,
@@ -109,14 +106,12 @@ public void deleteLabel(@PathVariable String id, @Validated LabelDTO label,
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
@ExceptionHandler(InvalidLabelError.class)
- @ResponseBody
public ErrorDTO handleValidationError(InvalidLabelError e) {
return ErrorDTO.fromString(e.getMessage());
}
@ResponseStatus(code = HttpStatus.NOT_FOUND)
@ExceptionHandler(NoSuchAccountError.class)
- @ResponseBody
public ErrorDTO handleNotFoundError(NoSuchAccountError e) {
return ErrorDTO.fromString(e.getMessage());
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java
new file mode 100644
index 000000000..ed3543025
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/DefaultIamTotpMfaService.java
@@ -0,0 +1,191 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication;
+
+import java.util.Optional;
+
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.ApplicationEventPublisherAware;
+import org.springframework.stereotype.Service;
+
+import dev.samstevens.totp.code.CodeVerifier;
+import dev.samstevens.totp.secret.SecretGenerator;
+import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppDisabledEvent;
+import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppEnabledEvent;
+import it.infn.mw.iam.audit.events.account.multi_factor_authentication.TotpVerifiedEvent;
+import it.infn.mw.iam.config.mfa.IamTotpMfaProperties;
+import it.infn.mw.iam.core.user.IamAccountService;
+import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException;
+import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
+import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError;
+
+@Service
+public class DefaultIamTotpMfaService implements IamTotpMfaService, ApplicationEventPublisherAware {
+
+ public static final int RECOVERY_CODE_QUANTITY = 6;
+ private static final String MFA_SECRET_NOT_FOUND_MESSAGE = "No multi-factor secret is attached to this account";
+
+ private final IamAccountService iamAccountService;
+ private final IamTotpMfaRepository totpMfaRepository;
+ private final SecretGenerator secretGenerator;
+ private final CodeVerifier codeVerifier;
+ private final IamTotpMfaProperties iamTotpMfaProperties;
+ private ApplicationEventPublisher eventPublisher;
+
+ public DefaultIamTotpMfaService(IamAccountService iamAccountService,
+ IamTotpMfaRepository totpMfaRepository, SecretGenerator secretGenerator,
+ CodeVerifier codeVerifier, ApplicationEventPublisher eventPublisher,
+ IamTotpMfaProperties iamTotpMfaProperties) {
+ this.iamAccountService = iamAccountService;
+ this.totpMfaRepository = totpMfaRepository;
+ this.secretGenerator = secretGenerator;
+ this.codeVerifier = codeVerifier;
+ this.eventPublisher = eventPublisher;
+ this.iamTotpMfaProperties = iamTotpMfaProperties;
+ }
+
+ private void authenticatorAppEnabledEvent(IamAccount account, IamTotpMfa totpMfa) {
+ eventPublisher.publishEvent(new AuthenticatorAppEnabledEvent(this, account, totpMfa));
+ }
+
+ private void authenticatorAppDisabledEvent(IamAccount account, IamTotpMfa totpMfa) {
+ eventPublisher.publishEvent(new AuthenticatorAppDisabledEvent(this, account, totpMfa));
+ }
+
+ private void totpVerifiedEvent(IamAccount account, IamTotpMfa totpMfa) {
+ eventPublisher.publishEvent(new TotpVerifiedEvent(this, account, totpMfa));
+ }
+
+ @Override
+ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
+ this.eventPublisher = applicationEventPublisher;
+ }
+
+ /**
+ * Generates and attaches a TOTP MFA secret to a user account
+ * This is pre-emptive to actually enabling TOTP MFA on the account - the secret is written for
+ * server-side TOTP verification during the user's enabling of MFA on their account
+ *
+ * @param account the account to add the secret to
+ * @return the new TOTP secret
+ */
+ @Override
+ public IamTotpMfa addTotpMfaSecret(IamAccount account) throws IamTotpMfaInvalidArgumentError {
+ Optional totpMfaOptional = totpMfaRepository.findByAccount(account);
+ if (totpMfaOptional.isPresent()) {
+ if (totpMfaOptional.get().isActive()) {
+ throw new MfaSecretAlreadyBoundException(
+ "A multi-factor secret is already assigned to this account");
+ }
+
+ totpMfaRepository.delete(totpMfaOptional.get());
+ }
+
+ // Generate secret
+ IamTotpMfa totpMfa = new IamTotpMfa(account);
+
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(
+ secretGenerator.generate(), iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ totpMfa.setAccount(account);
+
+ totpMfaRepository.save(totpMfa);
+
+ return totpMfa;
+ }
+
+ /**
+ * Enables TOTP MFA on a provided account. Relies on the account already having a non-active TOTP
+ * secret attached to it
+ *
+ * @param account the account to enable TOTP MFA on
+ * @return the newly-enabled TOTP secret
+ */
+ @Override
+ public IamTotpMfa enableTotpMfa(IamAccount account) {
+ Optional totpMfaOptional = totpMfaRepository.findByAccount(account);
+ if (!totpMfaOptional.isPresent()) {
+ throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE);
+ }
+
+ IamTotpMfa totpMfa = totpMfaOptional.get();
+ if (totpMfa.isActive()) {
+ throw new TotpMfaAlreadyEnabledException("TOTP MFA is already enabled on this account");
+ }
+
+ totpMfa.setActive(true);
+ totpMfa.touch();
+ totpMfaRepository.save(totpMfa);
+ iamAccountService.saveAccount(account);
+ authenticatorAppEnabledEvent(account, totpMfa);
+ return totpMfa;
+ }
+
+ /**
+ * Disables TOTP MFA on a provided account. Relies on the account having an active TOTP secret
+ * attached to it. Disabling means to delete the secret entirely (if a user chooses to enable
+ * again, a new secret is generated anyway)
+ *
+ * @param account the account to disable TOTP MFA on
+ * @return the newly-disabled TOTP MFA
+ */
+ @Override
+ public IamTotpMfa disableTotpMfa(IamAccount account) {
+ Optional totpMfaOptional = totpMfaRepository.findByAccount(account);
+ if (!totpMfaOptional.isPresent()) {
+ throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE);
+ }
+
+ IamTotpMfa totpMfa = totpMfaOptional.get();
+ totpMfaRepository.delete(totpMfa);
+
+ iamAccountService.saveAccount(account);
+ authenticatorAppDisabledEvent(account, totpMfa);
+ return totpMfa;
+ }
+
+ /**
+ * Verifies a provided TOTP against an account multi-factor secret
+ *
+ * @param account the account whose secret we will check against
+ * @param totp the TOTP to validate
+ * @return true if valid, false otherwise
+ */
+ @Override
+ public boolean verifyTotp(IamAccount account, String totp) throws IamTotpMfaInvalidArgumentError {
+ Optional totpMfaOptional = totpMfaRepository.findByAccount(account);
+ if (!totpMfaOptional.isPresent()) {
+ throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE);
+ }
+
+ IamTotpMfa totpMfa = totpMfaOptional.get();
+ String mfaSecret = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(
+ totpMfa.getSecret(), iamTotpMfaProperties.getPasswordToEncryptOrDecrypt());
+
+ // Verify provided TOTP
+ if (codeVerifier.isValidCode(mfaSecret, totp)) {
+ totpVerifiedEvent(account, totpMfa);
+ return true;
+ }
+
+ return false;
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java
new file mode 100644
index 000000000..db141d352
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/IamTotpMfaService.java
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+
+public interface IamTotpMfaService {
+
+ /**
+ * Generates and attaches a TOTP MFA secret to a user account
+ * This is pre-emptive to actually enabling TOTP MFA on the account - the secret is written for
+ * server-side TOTP verification during the user's enabling of MFA on their account
+ *
+ * @param account the account to add the secret to
+ * @return the new TOTP secret
+ */
+ IamTotpMfa addTotpMfaSecret(IamAccount account);
+
+ /**
+ * Enables TOTP MFA on a provided account. Relies on the account already having a non-active TOTP
+ * secret attached to it
+ *
+ * @param account the account to enable TOTP MFA on
+ * @return the newly-enabled TOTP secret
+ */
+ IamTotpMfa enableTotpMfa(IamAccount account);
+
+ /**
+ * Disables TOTP MFA on a provided account. Relies on the account having an active TOTP secret
+ * attached to it. Disabling means to delete the secret entirely (if a user chooses to enable
+ * again, a new secret is generated anyway)
+ *
+ * @param account the account to disable TOTP MFA on
+ * @return the newly-disabled TOTP MFA
+ */
+ IamTotpMfa disableTotpMfa(IamAccount account);
+
+ /**
+ * Verifies a provided TOTP against an account multi-factor secret
+ *
+ * @param account the account whose secret we will check against
+ * @param totp the TOTP to validate
+ * @return true if valid, false otherwise
+ */
+ boolean verifyTotp(IamAccount account, String totp);
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java
new file mode 100644
index 000000000..8db9c79a6
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsController.java
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication;
+
+import java.util.Optional;
+
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.provider.OAuth2Authentication;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import it.infn.mw.iam.api.common.NoSuchAccountError;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+
+/**
+ * Controller for retrieving all multi-factor settings for a user account
+ */
+@SuppressWarnings("deprecation")
+@Controller
+public class MultiFactorSettingsController {
+
+ public static final String MULTI_FACTOR_SETTINGS_URL = "/iam/multi-factor-settings";
+ public static final String MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL = "/iam/multi-factor-settings/{accountId}";
+ private final IamAccountRepository accountRepository;
+ private final IamTotpMfaRepository totpMfaRepository;
+
+ public MultiFactorSettingsController(IamAccountRepository accountRepository,
+ IamTotpMfaRepository totpMfaRepository) {
+ this.accountRepository = accountRepository;
+ this.totpMfaRepository = totpMfaRepository;
+ }
+
+ /**
+ * Retrieve info about MFA settings and return them in a DTO
+ *
+ * @return MultiFactorSettingsDTO the MFA settings for the account
+ */
+ @PreAuthorize("hasRole('ADMIN')")
+ @GetMapping(value = MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL, produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseBody
+ public MultiFactorSettingsDTO getMultiFactorSettingsForAccount(@PathVariable String accountId) {
+ IamAccount account = accountRepository.findByUuid(accountId).orElseThrow(() -> NoSuchAccountError.forUuid(accountId));
+
+ boolean isActive = totpMfaRepository.findByAccount(account)
+ .map(IamTotpMfa::isActive)
+ .orElse(false);
+
+ MultiFactorSettingsDTO dto = new MultiFactorSettingsDTO();
+ dto.setAuthenticatorAppActive(isActive);
+ return dto;
+ }
+
+
+ /**
+ * Retrieve info about MFA settings and return them in a DTO
+ *
+ * @return MultiFactorSettingsDTO the MFA settings for the account
+ */
+ @PreAuthorize("hasRole('USER')")
+ @GetMapping(value = MULTI_FACTOR_SETTINGS_URL,
+ produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseBody
+ public MultiFactorSettingsDTO getMultiFactorSettings() {
+
+ final String username = getUsernameFromSecurityContext();
+ IamAccount account = accountRepository.findByUsername(username)
+ .orElseThrow(() -> NoSuchAccountError.forUsername(username));
+ Optional totpMfaOptional = totpMfaRepository.findByAccount(account);
+ MultiFactorSettingsDTO dto = new MultiFactorSettingsDTO();
+ if (totpMfaOptional.isPresent()) {
+ IamTotpMfa totpMfa = totpMfaOptional.get();
+ dto.setAuthenticatorAppActive(totpMfa.isActive());
+ } else {
+ dto.setAuthenticatorAppActive(false);
+ }
+
+ // add further factors if/when implemented
+
+ return dto;
+ }
+
+
+ /**
+ * Fetch and return the logged-in username from security context
+ *
+ * @return String username
+ */
+ private String getUsernameFromSecurityContext() {
+
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth instanceof OAuth2Authentication) {
+ OAuth2Authentication oauth = (OAuth2Authentication) auth;
+ auth = oauth.getUserAuthentication();
+ }
+ return auth.getName();
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsDTO.java
new file mode 100644
index 000000000..06fbd9492
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/MultiFactorSettingsDTO.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication;
+
+import javax.validation.constraints.NotEmpty;
+
+import com.nimbusds.jose.shaded.json.JSONObject;
+
+/**
+ * DTO containing info about enabled factors of authentication
+ */
+public class MultiFactorSettingsDTO {
+
+ @NotEmpty
+ private boolean authenticatorAppActive;
+
+ // add further factors if/when implemented
+
+ public MultiFactorSettingsDTO() {}
+
+ public MultiFactorSettingsDTO(final boolean authenticatorAppActive) {
+ this.authenticatorAppActive = authenticatorAppActive;
+ }
+
+
+ /**
+ * @return true if authenticator app is active
+ */
+ public boolean getAuthenticatorAppActive() {
+ return authenticatorAppActive;
+ }
+
+
+ /**
+ * @param authenticatorAppActive new status of authenticator app
+ */
+ public void setAuthenticatorAppActive(final boolean authenticatorAppActive) {
+ this.authenticatorAppActive = authenticatorAppActive;
+ }
+
+ public JSONObject toJson() {
+ JSONObject json = new JSONObject();
+ json.put("authenticatorAppActive", authenticatorAppActive);
+ return json;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsController.java
new file mode 100644
index 000000000..bf449aff5
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsController.java
@@ -0,0 +1,301 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app;
+
+import static dev.samstevens.totp.util.Utils.getDataUriForImage;
+
+import javax.validation.Valid;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.provider.OAuth2Authentication;
+import org.springframework.stereotype.Controller;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+import dev.samstevens.totp.code.HashingAlgorithm;
+import dev.samstevens.totp.exceptions.QrGenerationException;
+import dev.samstevens.totp.qr.QrData;
+import dev.samstevens.totp.qr.QrGenerator;
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.error.BadMfaCodeError;
+import it.infn.mw.iam.api.common.ErrorDTO;
+import it.infn.mw.iam.api.common.NoSuchAccountError;
+import it.infn.mw.iam.config.IamProperties;
+import it.infn.mw.iam.config.mfa.IamTotpMfaProperties;
+import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException;
+import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
+import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException;
+import it.infn.mw.iam.notification.NotificationFactory;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError;
+
+/**
+ * Controller for customising user's authenticator app MFA settings Can enable or disable the
+ * feature through POST requests to the relevant endpoints
+ */
+@SuppressWarnings("deprecation")
+@Controller
+public class AuthenticatorAppSettingsController {
+
+ public static final String BASE_URL = "/iam/authenticator-app";
+ public static final String ADD_SECRET_URL = BASE_URL + "/add-secret";
+ public static final String ENABLE_URL = BASE_URL + "/enable";
+ public static final String DISABLE_URL = BASE_URL + "/disable";
+ public static final String DISABLE_URL_FOR_ACCOUNT_ID = BASE_URL + "/reset/{accountId}";
+ public static final String BAD_CODE = "Bad TOTP";
+ public static final String CODE_GENERATION_ERROR = "Could not generate QR code";
+ public static final String MFA_SECRET_NOT_FOUND_MESSAGE =
+ "No multi-factor secret is attached to this account";
+
+ private final IamTotpMfaService service;
+ private final IamAccountRepository accountRepository;
+ private final QrGenerator qrGenerator;
+ private final IamTotpMfaProperties iamTotpMfaProperties;
+ private final IamProperties iamProperties;
+ private final NotificationFactory notificationFactory;
+
+ public AuthenticatorAppSettingsController(IamTotpMfaService service,
+ IamAccountRepository accountRepository, QrGenerator qrGenerator,
+ IamTotpMfaProperties iamTotpMfaProperties, IamProperties iamProperties,
+ NotificationFactory notificationFactory) {
+ this.service = service;
+ this.accountRepository = accountRepository;
+ this.qrGenerator = qrGenerator;
+ this.iamTotpMfaProperties = iamTotpMfaProperties;
+ this.iamProperties = iamProperties;
+ this.notificationFactory = notificationFactory;
+ }
+
+ /**
+ * Before we can enable authenticator app, we must first add a TOTP secret to the user's account
+ * The secret is not active until the user enables authenticator app at the /enable endpoint
+ *
+ * @return DTO containing the plaintext TOTP secret and QR code URI for scanning
+ */
+ @PreAuthorize("hasRole('USER')")
+ @PutMapping(value = ADD_SECRET_URL, produces = MediaType.APPLICATION_JSON_VALUE)
+ @ResponseBody
+ public SecretAndDataUriDTO addSecret() throws IamTotpMfaInvalidArgumentError {
+ final String username = getUsernameFromSecurityContext();
+ IamAccount account = accountRepository.findByUsername(username)
+ .orElseThrow(() -> NoSuchAccountError.forUsername(username));
+
+ IamTotpMfa totpMfa = service.addTotpMfaSecret(account);
+ String mfaSecret = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(totpMfa.getSecret(),
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt());
+
+ try {
+ SecretAndDataUriDTO dto = new SecretAndDataUriDTO(mfaSecret);
+
+ String dataUri = generateQRCodeFromSecret(mfaSecret, account.getUsername());
+ dto.setDataUri(dataUri);
+
+ return dto;
+ } catch (QrGenerationException e) {
+ throw new BadMfaCodeError(CODE_GENERATION_ERROR);
+ }
+ }
+
+ /**
+ * Enable authenticator app MFA on account User sends a TOTP through POST which we verify before
+ * enabling
+ *
+ * @param code the TOTP to verify
+ * @param validationResult result of validation checks on the code
+ * @return nothing
+ */
+ @PreAuthorize("hasRole('USER')")
+ @PostMapping(value = ENABLE_URL, produces = MediaType.TEXT_PLAIN_VALUE)
+ @ResponseBody
+ public void enableAuthenticatorApp(@ModelAttribute @Valid CodeDTO code,
+ BindingResult validationResult) {
+ if (validationResult.hasErrors()) {
+ throw new BadMfaCodeError(BAD_CODE);
+ }
+
+ final String username = getUsernameFromSecurityContext();
+ IamAccount account = accountRepository.findByUsername(username)
+ .orElseThrow(() -> NoSuchAccountError.forUsername(username));
+
+ boolean valid = false;
+
+ try {
+ valid = service.verifyTotp(account, code.getCode());
+ } catch (MfaSecretNotFoundException e) {
+ throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE);
+ }
+
+ if (!valid) {
+ throw new BadMfaCodeError(BAD_CODE);
+ }
+
+ service.enableTotpMfa(account);
+ notificationFactory.createMfaEnableMessage(account);
+ }
+
+
+ /**
+ * Disable authenticator app MFA on account User sends a TOTP through POST which we verify before
+ * disabling
+ *
+ * @param code the TOTP to verify
+ * @param validationResult result of validation checks on the code
+ * @return nothing
+ */
+ @PreAuthorize("hasRole('USER')")
+ @PostMapping(value = DISABLE_URL, produces = MediaType.TEXT_PLAIN_VALUE)
+ @ResponseBody
+ public void disableAuthenticatorApp(@Valid CodeDTO code, BindingResult validationResult) {
+ if (validationResult.hasErrors()) {
+ throw new BadMfaCodeError(BAD_CODE);
+ }
+
+ final String username = getUsernameFromSecurityContext();
+ IamAccount account = accountRepository.findByUsername(username)
+ .orElseThrow(() -> NoSuchAccountError.forUsername(username));
+
+ boolean valid = false;
+
+ try {
+ valid = service.verifyTotp(account, code.getCode());
+ } catch (MfaSecretNotFoundException e) {
+ throw new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE);
+ }
+
+ if (!valid) {
+ throw new BadMfaCodeError(BAD_CODE);
+ }
+
+ service.disableTotpMfa(account);
+ notificationFactory.createMfaDisableMessage(account);
+ }
+
+ /**
+ * Reset authenticator app MFA on account by Admin on request
+ *
+ * @param accountId the accountId to get user account
+ * @return nothing
+ */
+ @PreAuthorize("hasRole('ADMIN')")
+ @DeleteMapping(value = DISABLE_URL_FOR_ACCOUNT_ID, produces = MediaType.TEXT_PLAIN_VALUE)
+ @ResponseBody
+ public void disableAuthenticatorAppForAccount(@PathVariable String accountId) {
+ IamAccount account = accountRepository.findByUuid(accountId)
+ .orElseThrow(() -> NoSuchAccountError.forUuid(accountId));
+ service.disableTotpMfa(account);
+ notificationFactory.createMfaDisableMessage(account);
+ }
+
+ /**
+ * Fetch and return the logged-in username from security context
+ *
+ * @return String username
+ */
+ private String getUsernameFromSecurityContext() {
+
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth instanceof OAuth2Authentication) {
+ OAuth2Authentication oauth = (OAuth2Authentication) auth;
+ auth = oauth.getUserAuthentication();
+ }
+ return auth.getName();
+ }
+
+ /**
+ * Constructs a data URI for displaying a QR code of the TOTP secret for the user to scan Takes in
+ * details about the issuer, length of TOTP and period of expiry from application properties
+ *
+ * @param secret the TOTP secret
+ * @param username the logged-in user (attaches a username to the secret in the authenticator app)
+ * @return the data URI to be used with an tag
+ * @throws QrGenerationException
+ */
+ private String generateQRCodeFromSecret(String secret, String username)
+ throws QrGenerationException {
+
+ QrData data = new QrData.Builder().label(username)
+ .secret(secret)
+ .issuer("INDIGO IAM" + " - " + iamProperties.getOrganisation().getName())
+ .algorithm(HashingAlgorithm.SHA1)
+ .digits(6)
+ .period(30)
+ .build();
+
+ byte[] imageData = qrGenerator.generate(data);
+ String mimeType = qrGenerator.getImageMimeType();
+ return getDataUriForImage(imageData, mimeType);
+ }
+
+
+ /**
+ * Exception handler for when an TOTP secret is unexpectedly missing
+ *
+ * @param e MfaSecretNotFoundException
+ * @return DTO containing error details
+ */
+ @ResponseStatus(code = HttpStatus.CONFLICT)
+ @ExceptionHandler(MfaSecretNotFoundException.class)
+ @ResponseBody
+ public ErrorDTO handleMfaSecretNotFoundException(MfaSecretNotFoundException e) {
+ return ErrorDTO.fromString(e.getMessage());
+ }
+
+ /**
+ * Exception handler for when an TOTP secret is unexpectedly found
+ *
+ * @param e MfaSecretAlreadyBoundException
+ * @return DTO containing error details
+ */
+ @ResponseStatus(code = HttpStatus.CONFLICT)
+ @ExceptionHandler(MfaSecretAlreadyBoundException.class)
+ @ResponseBody
+ public ErrorDTO handleMfaSecretAlreadyBoundException(MfaSecretAlreadyBoundException e) {
+ return ErrorDTO.fromString(e.getMessage());
+ }
+
+ /**
+ * Exception handler for when authenticator app MFA is unexpectedly enabled already
+ *
+ * @param e TotpMfaAlreadyEnabledException
+ * @return DTO containing error details
+ */
+ @ResponseStatus(code = HttpStatus.CONFLICT)
+ @ExceptionHandler(TotpMfaAlreadyEnabledException.class)
+ @ResponseBody
+ public ErrorDTO handleTotpMfaAlreadyEnabledException(TotpMfaAlreadyEnabledException e) {
+ return ErrorDTO.fromString(e.getMessage());
+ }
+
+
+ /**
+ * Exception handler for when a received TOTP is invalid
+ *
+ * @param e BadCodeError
+ * @return DTO containing error details
+ */
+ @ResponseStatus(code = HttpStatus.BAD_REQUEST)
+ @ExceptionHandler(BadMfaCodeError.class)
+ @ResponseBody
+ public ErrorDTO handleBadCodeError(BadMfaCodeError e) {
+ return ErrorDTO.fromString(e.getMessage());
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/CodeDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/CodeDTO.java
new file mode 100644
index 000000000..5264458f9
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/CodeDTO.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotEmpty;
+
+import org.hibernate.validator.constraints.Length;
+
+/**
+ * DTO containing a TOTP for MFA secrets
+ */
+public class CodeDTO {
+
+ @NotEmpty(message = "Code cannot be empty")
+ @Length(min = 6, max = 6, message = "Code must be six characters in length")
+ @Min(value = 0L, message = "Code must be a numerical value")
+ private String code;
+
+
+ /**
+ * @return the code
+ */
+ public String getCode() {
+ return code;
+ }
+
+
+ /**
+ * @param code new code
+ */
+ public void setCode(final String code) {
+ this.code = code;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/SecretAndDataUriDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/SecretAndDataUriDTO.java
new file mode 100644
index 000000000..649293dc4
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/SecretAndDataUriDTO.java
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app;
+
+import javax.validation.constraints.NotEmpty;
+
+/**
+ * DTO containing an MFA secret and QR code data URI
+ */
+public class SecretAndDataUriDTO {
+
+ @NotEmpty(message = "Secret cannot be empty")
+ private String secret;
+
+ private String dataUri;
+
+ public SecretAndDataUriDTO(final String secret) {
+ this.secret = secret;
+ }
+
+
+ /**
+ * @return the MFA secret
+ */
+ public String getSecret() {
+ return secret;
+ }
+
+
+ /**
+ * @param secret the new secret
+ */
+ public void setSecret(final String secret) {
+ this.secret = secret;
+ }
+
+
+ /**
+ * @return the QR code data URI
+ */
+ public String getDataUri() {
+ return dataUri;
+ }
+
+
+ /**
+ * @param dataUri the new QR code data URI
+ */
+ public void setDataUri(final String dataUri) {
+ this.dataUri = dataUri;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/error/BadMfaCodeError.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/error/BadMfaCodeError.java
new file mode 100644
index 000000000..88ab60b00
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account/multi_factor_authentication/authenticator_app/error/BadMfaCodeError.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.error;
+
+public class BadMfaCodeError extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public BadMfaCodeError(String msg) {
+ super(msg);
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/AccountLinkingController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/AccountLinkingController.java
index 43b8e9cd1..c917308f1 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/AccountLinkingController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/AccountLinkingController.java
@@ -25,15 +25,16 @@
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
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.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@@ -54,14 +55,13 @@ public class AccountLinkingController extends ExternalAuthenticationHandlerSuppo
@Value("${iam.account-linking.enable}")
private Boolean accountLinkingEnabled;
- @Autowired
public AccountLinkingController(AccountLinkingService s) {
linkingService = s;
}
@PreAuthorize("hasRole('USER')")
- @RequestMapping(value = "/X509", method = RequestMethod.DELETE)
+ @DeleteMapping(value = "/X509")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
public void unlinkX509Certificate(Principal principal, @RequestParam String certificateSubject,
RedirectAttributes attributes) {
@@ -72,7 +72,7 @@ public void unlinkX509Certificate(Principal principal, @RequestParam String cert
@PreAuthorize("hasRole('USER')")
- @RequestMapping(value = "/X509", method = RequestMethod.POST)
+ @PostMapping(value = "/X509")
public String linkX509Certificate(HttpSession session, Principal principal,
RedirectAttributes attributes) {
@@ -104,7 +104,7 @@ private void checkAccountLinkingEnabled(RedirectAttributes attributes) {
}
@PreAuthorize("hasRole('USER')")
- @RequestMapping(value = "/{type}", method = RequestMethod.POST)
+ @PostMapping(value = "/{type}")
public void linkAccount(@PathVariable ExternalAuthenticationType type,
@RequestParam(value = "id", required = false) String externalIdpId, Authentication authn,
final RedirectAttributes redirectAttributes, HttpServletRequest request,
@@ -163,7 +163,7 @@ public String finalizeAccountLinking(@PathVariable ExternalAuthenticationType ty
}
@PreAuthorize("hasRole('USER')")
- @RequestMapping(value = "/{type}", method = RequestMethod.DELETE)
+ @DeleteMapping(value = "/{type}")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
public void unlinkAccount(@PathVariable ExternalAuthenticationType type, Principal principal,
@RequestParam("iss") String issuer, @RequestParam("sub") String subject,
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/DefaultAccountLinkingService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/DefaultAccountLinkingService.java
index 15c06826b..e483bce3d 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/DefaultAccountLinkingService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/account_linking/DefaultAccountLinkingService.java
@@ -22,7 +22,6 @@
import java.util.Date;
import java.util.Optional;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -30,9 +29,9 @@
import it.infn.mw.iam.audit.events.account.AccountLinkedEvent;
import it.infn.mw.iam.audit.events.account.AccountUnlinkedEvent;
-import it.infn.mw.iam.audit.events.account.X509CertificateLinkedEvent;
-import it.infn.mw.iam.audit.events.account.X509CertificateUnlinkedEvent;
import it.infn.mw.iam.audit.events.account.X509CertificateUpdatedEvent;
+import it.infn.mw.iam.audit.events.account.x509.X509CertificateLinkedEvent;
+import it.infn.mw.iam.audit.events.account.x509.X509CertificateUnlinkedEvent;
import it.infn.mw.iam.authn.AbstractExternalAuthenticationToken;
import it.infn.mw.iam.authn.ExternalAccountLinker;
import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType;
@@ -44,18 +43,21 @@
import it.infn.mw.iam.persistence.model.IamX509Certificate;
import it.infn.mw.iam.persistence.model.IamX509ProxyCertificate;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamX509CertificateRepository;
@Service
public class DefaultAccountLinkingService
implements AccountLinkingService, ApplicationEventPublisherAware {
final IamAccountRepository iamAccountRepository;
+ final IamX509CertificateRepository certificateRepository;
final ExternalAccountLinker externalAccountLinker;
private ApplicationEventPublisher eventPublisher;
- @Autowired
- public DefaultAccountLinkingService(IamAccountRepository repo, ExternalAccountLinker linker) {
+ public DefaultAccountLinkingService(IamAccountRepository repo,
+ IamX509CertificateRepository certificateRepository, ExternalAccountLinker linker) {
this.iamAccountRepository = repo;
+ this.certificateRepository = certificateRepository;
this.externalAccountLinker = linker;
}
@@ -140,27 +142,26 @@ public void linkX509Certificate(Principal authenticatedUser,
IamAccount userAccount = findAccount(authenticatedUser);
- iamAccountRepository.findByCertificateSubject(x509Credential.getSubject())
- .ifPresent(linkedAccount -> {
- if (!linkedAccount.getUuid().equals(userAccount.getUuid())) {
- throw new AccountAlreadyLinkedError(
- format("X.509 credential with subject '%s' is already linked to another user",
- x509Credential.getSubject()));
- }
- });
+ Optional linkedAccount =
+ certificateRepository.findBySubjectDn(x509Credential.getSubject()).stream().findFirst();
- Optional linkedCert = userAccount.getX509Certificates()
- .stream()
- .filter(c -> c.getSubjectDn().equals(x509Credential.getSubject()) && c.getIssuerDn().equals(x509Credential.getIssuer()))
- .findAny();
+ // check if the x509Credential is linked to another user
+ if (linkedAccount.isPresent() && !linkedAccount.get().getUuid().equals(userAccount.getUuid())) {
+ throw new AccountAlreadyLinkedError(
+ format("X.509 credential with subject '%s' is already linked to another user",
+ x509Credential.getSubject()));
+ }
- if (linkedCert.isPresent()) {
+ Optional linkedCertificate = certificateRepository
+ .findBySubjectDnAndIssuerDn(x509Credential.getSubject(), x509Credential.getIssuer());
- linkedCert.ifPresent(c -> {
- c.setCertificate(x509Credential.getCertificateChainPemString());
- c.setLastUpdateTime(new Date());
- });
+ if (linkedCertificate.isPresent()) {
+ linkedCertificate.get().setCertificate(x509Credential.getCertificateChainPemString());
+ linkedCertificate.get().setLastUpdateTime(new Date());
+ certificateRepository.save(linkedCertificate.get());
+ userAccount.getX509Certificates().remove(linkedCertificate.get());
+ userAccount.getX509Certificates().add(linkedCertificate.get());
userAccount.touch();
iamAccountRepository.save(userAccount);
@@ -168,28 +169,23 @@ public void linkX509Certificate(Principal authenticatedUser,
String.format("User '%s' has updated its linked certificate with subject '%s'",
userAccount.getUsername(), x509Credential.getSubject()),
x509Credential));
-
} else {
Date now = new Date();
IamX509Certificate newCert = x509Credential.asIamX509Certificate();
newCert.setLabel(String.format("cert-%d", userAccount.getX509Certificates().size()));
-
newCert.setCreationTime(now);
newCert.setLastUpdateTime(now);
-
newCert.setPrimary(true);
newCert.setAccount(userAccount);
+ certificateRepository.save(newCert);
userAccount.getX509Certificates().add(newCert);
userAccount.touch();
-
iamAccountRepository.save(userAccount);
-
eventPublisher.publishEvent(new X509CertificateLinkedEvent(this, userAccount,
String.format("User '%s' linked certificate with subject '%s' to his/her membership",
userAccount.getUsername(), x509Credential.getSubject()),
x509Credential));
-
}
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/aup/AupController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/aup/AupController.java
index 9cc974e9a..847fa8b26 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/aup/AupController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/aup/AupController.java
@@ -17,16 +17,17 @@
import javax.validation.Valid;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -45,7 +46,6 @@ public class AupController {
private final AupService service;
private final AupConverter converter;
- @Autowired
public AupController(AupService service, AupConverter converter) {
this.service = service;
this.converter = converter;
@@ -57,14 +57,14 @@ private RuntimeException buildValidationError(BindingResult validationResult) {
return new InvalidAupError(firstErrorMessage);
}
- @RequestMapping(value = "/iam/aup", method = RequestMethod.GET)
+ @GetMapping(value = "/iam/aup")
public AupDTO getAup() {
IamAup aup = service.findAup().orElseThrow(AupNotFoundError::new);
return converter.dtoFromEntity(aup);
}
- @RequestMapping(value = "/iam/aup", method = RequestMethod.POST)
+ @PostMapping(value = "/iam/aup")
@ResponseStatus(code = HttpStatus.CREATED)
@PreAuthorize("hasRole('ADMIN')")
public void createAup(@Valid @RequestBody AupDTO aup, BindingResult validationResult) {
@@ -79,7 +79,7 @@ public void createAup(@Valid @RequestBody AupDTO aup, BindingResult validationRe
service.saveAup(aup);
}
- @RequestMapping(value = "/iam/aup", method = RequestMethod.PATCH)
+ @PatchMapping(value = "/iam/aup")
@ResponseStatus(code = HttpStatus.OK)
@PreAuthorize("hasRole('ADMIN')")
public AupDTO updateAup(@Valid @RequestBody AupDTO aup, BindingResult validationResult) {
@@ -92,7 +92,7 @@ public AupDTO updateAup(@Valid @RequestBody AupDTO aup, BindingResult validation
return converter.dtoFromEntity(updatedAup);
}
- @RequestMapping(value = "/iam/aup/touch", method = RequestMethod.POST)
+ @PostMapping(value = "/iam/aup/touch")
@ResponseStatus(code = HttpStatus.OK)
@PreAuthorize("hasRole('ADMIN')")
public AupDTO touchAup() {
@@ -101,7 +101,7 @@ public AupDTO touchAup() {
return converter.dtoFromEntity(updatedAup);
}
- @RequestMapping(value = "/iam/aup", method = RequestMethod.DELETE)
+ @DeleteMapping(value = "/iam/aup")
@ResponseStatus(code = HttpStatus.NO_CONTENT)
@PreAuthorize("hasRole('ADMIN')")
public void deleteAup() {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/AttributeDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/AttributeDTO.java
index 8d69b95f2..1dedc6409 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/AttributeDTO.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/AttributeDTO.java
@@ -16,9 +16,12 @@
package it.infn.mw.iam.api.common;
import javax.annotation.Generated;
+import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
+import it.infn.mw.iam.api.common.validator.NoNewLineOrCarriageReturn;
+
public class AttributeDTO {
@@ -28,9 +31,11 @@ public class AttributeDTO {
@Size(max = 64, message = "name cannot be longer than 64 chars")
@Pattern(regexp = NAME_REGEXP,
message = "invalid name (does not match with regexp: '" + NAME_REGEXP + "')")
+ @NotBlank
private String name;
@Size(max = 256, message = "value cannot be longer than 256 chars")
+ @NoNewLineOrCarriageReturn
private String value;
public AttributeDTO() {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/LabelDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/LabelDTO.java
index afa8b1fdf..2c61ba430 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/LabelDTO.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/LabelDTO.java
@@ -16,13 +16,14 @@
package it.infn.mw.iam.api.common;
import javax.annotation.Generated;
+import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
-import javax.validation.constraints.NotBlank;
-
import com.fasterxml.jackson.annotation.JsonInclude;
+import it.infn.mw.iam.api.common.validator.NoNewLineOrCarriageReturn;
+
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class LabelDTO {
@@ -43,6 +44,7 @@ public class LabelDTO {
private String name;
@Size(max = 64, message = "invalid value length")
+ @NoNewLineOrCarriageReturn
private String value;
public LabelDTO() {}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NoNewLineOrCarriageReturn.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NoNewLineOrCarriageReturn.java
new file mode 100644
index 000000000..ed036740c
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NoNewLineOrCarriageReturn.java
@@ -0,0 +1,39 @@
+/**
+ * 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.common.validator;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import javax.validation.Constraint;
+import javax.validation.Payload;
+
+@Retention(RUNTIME)
+@Target({FIELD, METHOD})
+@Constraint(validatedBy = NoNewLineOrCarriageReturnValidator.class)
+public @interface NoNewLineOrCarriageReturn {
+
+ String message() default "The string must not contain any new line or carriage return";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NoNewLineOrCarriageReturnValidator.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NoNewLineOrCarriageReturnValidator.java
new file mode 100644
index 000000000..0a9f41cf9
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/validator/NoNewLineOrCarriageReturnValidator.java
@@ -0,0 +1,37 @@
+/**
+ * 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.common.validator;
+
+import javax.validation.ConstraintValidator;
+import javax.validation.ConstraintValidatorContext;
+
+public class NoNewLineOrCarriageReturnValidator implements ConstraintValidator {
+
+ public NoNewLineOrCarriageReturnValidator() {
+ // Empty on purpose
+ }
+
+ @Override
+ public void initialize(NoNewLineOrCarriageReturn constraintAnnotation) {
+ // Empty on purpose
+ }
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext context) {
+ return value == null || !value.matches(".*(?:[ \r\n\t].*)+");
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/GroupLabelsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/GroupLabelsController.java
index c07cc9a5c..a2324c821 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/group/GroupLabelsController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/group/GroupLabelsController.java
@@ -18,22 +18,20 @@
import static it.infn.mw.iam.api.utils.ValidationErrorUtils.stringifyValidationError;
import static java.lang.String.format;
import static org.springframework.http.HttpStatus.NO_CONTENT;
-import static org.springframework.web.bind.annotation.RequestMethod.DELETE;
-import static org.springframework.web.bind.annotation.RequestMethod.GET;
-import static org.springframework.web.bind.annotation.RequestMethod.PUT;
import java.util.List;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -58,7 +56,6 @@ public class GroupLabelsController {
final IamGroupService service;
final LabelDTOConverter converter;
- @Autowired
public GroupLabelsController(IamGroupService service, LabelDTOConverter converter) {
this.service = service;
this.converter = converter;
@@ -70,7 +67,7 @@ private void handleValidationError(BindingResult result) {
}
}
- @RequestMapping(method = GET)
+ @GetMapping
@PreAuthorize("hasRole('ADMIN') or #iam.isGroupManager(#id)")
public List getLabels(@PathVariable String id) {
@@ -83,7 +80,7 @@ public List getLabels(@PathVariable String id) {
return results;
}
- @RequestMapping(method = PUT)
+ @PutMapping
public void setLabel(@PathVariable String id, @RequestBody @Validated LabelDTO label,
BindingResult validationResult) {
handleValidationError(validationResult);
@@ -92,7 +89,7 @@ public void setLabel(@PathVariable String id, @RequestBody @Validated LabelDTO l
service.addLabel(group, converter.entityFromDto(label));
}
- @RequestMapping(method = DELETE)
+ @DeleteMapping
@ResponseStatus(NO_CONTENT)
public void deleteLabel(@PathVariable String id, @Validated LabelDTO label,
BindingResult validationResult) {
@@ -103,14 +100,12 @@ public void deleteLabel(@PathVariable String id, @Validated LabelDTO label,
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
@ExceptionHandler(InvalidLabelError.class)
- @ResponseBody
public ErrorDTO handleValidationError(InvalidLabelError e) {
return ErrorDTO.fromString(e.getMessage());
}
@ResponseStatus(code = HttpStatus.NOT_FOUND)
@ExceptionHandler(NoSuchGroupError.class)
- @ResponseBody
public ErrorDTO handleNotFoundError(NoSuchGroupError e) {
return ErrorDTO.fromString(e.getMessage());
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernHrDBApiService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernHrDBApiService.java
index f02ed1c42..0ecbfdebf 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernHrDBApiService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/CernHrDBApiService.java
@@ -15,13 +15,24 @@
*/
package it.infn.mw.iam.api.registration.cern;
+import java.util.Optional;
+
import org.springframework.context.annotation.Profile;
+import org.springframework.web.client.RestClientException;
import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO;
@Profile("cern")
public interface CernHrDBApiService {
- VOPersonDTO getHrDbPersonRecord(String personId);
+ /**
+ * Returns an @Optional object that contains the @VOPersonDTO related to the CERN person ID
+ * provided as parameter or empty if not found.
+ *
+ * @param personId
+ * @return
+ * @throws RestClientException in case of ApiErrors
+ */
+ Optional getHrDbPersonRecord(String personId) throws RestClientException;
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java
index 194e893bc..8c2ca8cf7 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/DefaultCernHrDBApiService.java
@@ -17,6 +17,9 @@
import static it.infn.mw.iam.util.BasicAuthenticationUtils.basicAuthHeaderValue;
import static java.lang.String.format;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,6 +29,7 @@
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@@ -63,7 +67,7 @@ private HttpHeaders buildAuthHeaders() {
}
@Override
- public VOPersonDTO getHrDbPersonRecord(String personId) {
+ public Optional getHrDbPersonRecord(String personId) {
RestTemplate rt = rtFactory.newRestTemplate();
@@ -75,10 +79,13 @@ public VOPersonDTO getHrDbPersonRecord(String personId) {
try {
ResponseEntity response = rt.exchange(personValidUrl, HttpMethod.GET,
new HttpEntity<>(buildAuthHeaders()), VOPersonDTO.class);
- return response.getBody();
+ return Optional.of(response.getBody());
} catch (RestClientException e) {
- final String errorMsg = "HR db api error: " + e.getMessage();
- throw new CernHrDbApiError(errorMsg, e);
+ if ((e instanceof HttpClientErrorException)
+ && (((HttpClientErrorException) e).getStatusCode().equals(NOT_FOUND))) {
+ return Optional.empty();
+ }
+ throw new CernHrDbApiError(e.getMessage(), e);
}
}
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/mock/MockCernAuthController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/mock/MockCernAuthController.java
index fb44952d2..d5bf8105d 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/mock/MockCernAuthController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/registration/cern/mock/MockCernAuthController.java
@@ -20,6 +20,7 @@
import java.time.Instant;
import java.util.Date;
+import java.util.Optional;
import javax.servlet.http.HttpSession;
@@ -69,7 +70,7 @@ public String mockCernAuthentication(HttpSession session) {
}
@Override
- public VOPersonDTO getHrDbPersonRecord(String personId) {
+ public Optional getHrDbPersonRecord(String personId) {
VOPersonDTO dto = new VOPersonDTO();
dto.setFirstName("TEST");
dto.setName("USER");
@@ -89,6 +90,6 @@ public VOPersonDTO getHrDbPersonRecord(String personId) {
p.setInstitute(i);
dto.getParticipations().add(p);
- return dto;
+ return Optional.of(dto);
}
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/controller/ScimUserController.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/controller/ScimUserController.java
index 11e2e4606..a69744586 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/controller/ScimUserController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/controller/ScimUserController.java
@@ -27,10 +27,14 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -76,7 +80,7 @@ private Set parseAttributes(final String attributesParameter) {
}
@PreAuthorize("#iam.hasScope('scim:read') or #iam.hasDashboardRole('ROLE_ADMIN')")
- @RequestMapping(method = RequestMethod.GET, produces = ScimConstants.SCIM_CONTENT_TYPE)
+ @GetMapping(produces = ScimConstants.SCIM_CONTENT_TYPE)
public MappingJacksonValue listUsers(@RequestParam(required = false) final Integer count,
@RequestParam(required = false) final Integer startIndex,
@RequestParam(required = false) final String attributes) {
@@ -99,15 +103,14 @@ public MappingJacksonValue listUsers(@RequestParam(required = false) final Integ
}
@PreAuthorize("#iam.hasScope('scim:read') or #iam.hasAnyDashboardRole('ROLE_ADMIN', 'ROLE_GM')")
- @RequestMapping(value = "/{id}", method = RequestMethod.GET,
- produces = ScimConstants.SCIM_CONTENT_TYPE)
+ @GetMapping(value = "/{id}", produces = ScimConstants.SCIM_CONTENT_TYPE)
public ScimUser getUser(@PathVariable final String id) {
return userProvisioningService.getById(id);
}
@PreAuthorize("#iam.hasScope('scim:write') or #iam.hasDashboardRole('ROLE_ADMIN')")
- @RequestMapping(method = RequestMethod.POST, consumes = ScimConstants.SCIM_CONTENT_TYPE,
+ @PostMapping(consumes = ScimConstants.SCIM_CONTENT_TYPE,
produces = ScimConstants.SCIM_CONTENT_TYPE)
@ResponseStatus(HttpStatus.CREATED)
public MappingJacksonValue create(
@@ -121,8 +124,8 @@ public MappingJacksonValue create(
}
@PreAuthorize("#iam.hasScope('scim:write') or #iam.hasDashboardRole('ROLE_ADMIN')")
- @RequestMapping(value = "/{id}", method = RequestMethod.PUT,
- consumes = ScimConstants.SCIM_CONTENT_TYPE, produces = ScimConstants.SCIM_CONTENT_TYPE)
+ @PutMapping(value = "/{id}", consumes = ScimConstants.SCIM_CONTENT_TYPE,
+ produces = ScimConstants.SCIM_CONTENT_TYPE)
@ResponseStatus(HttpStatus.OK)
public ScimUser replaceUser(@PathVariable final String id,
@RequestBody @Validated(ScimUser.NewUserValidation.class) final ScimUser user,
@@ -135,8 +138,7 @@ public ScimUser replaceUser(@PathVariable final String id,
}
@PreAuthorize("#iam.hasScope('scim:write') or #iam.hasDashboardRole('ROLE_ADMIN')")
- @RequestMapping(value = "/{id}", method = RequestMethod.PATCH,
- consumes = ScimConstants.SCIM_CONTENT_TYPE)
+ @PatchMapping(value = "/{id}", consumes = ScimConstants.SCIM_CONTENT_TYPE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void updateUser(@PathVariable final String id,
@RequestBody @Validated(ScimUser.UpdateUserValidation.class) final ScimUserPatchRequest patchRequest,
@@ -149,7 +151,7 @@ public void updateUser(@PathVariable final String id,
}
@PreAuthorize("#iam.hasScope('scim:write') or #iam.hasDashboardRole('ROLE_ADMIN')")
- @RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
+ @DeleteMapping(value = "/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable final String id) {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java
index cdb64ebed..dfad85f5f 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/scim/model/ScimAttribute.java
@@ -71,4 +71,4 @@ public ScimAttribute build() {
return new ScimAttribute(this);
}
}
-}
+}
\ No newline at end of file
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/AccountUnlinkedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/AccountUnlinkedEvent.java
index c621ff85c..89631e3db 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/AccountUnlinkedEvent.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/AccountUnlinkedEvent.java
@@ -15,6 +15,8 @@
*/
package it.infn.mw.iam.audit.events.account;
+import static it.infn.mw.iam.audit.events.utils.EventUtils.sanitize;
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType;
@@ -34,8 +36,8 @@ public AccountUnlinkedEvent(Object source, IamAccount account,
ExternalAuthenticationType accountType, String issuer, String subject, String message) {
super(source, account, message);
this.externalAuthenticationType = accountType;
- this.issuer = issuer;
- this.subject = subject;
+ this.issuer = sanitize(issuer);
+ this.subject = sanitize(subject);
}
public ExternalAuthenticationType getExternalAuthenticationType() {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUnlinkedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUnlinkedEvent.java
index 80159d66f..a775bad00 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUnlinkedEvent.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUnlinkedEvent.java
@@ -15,6 +15,8 @@
*/
package it.infn.mw.iam.audit.events.account;
+import static it.infn.mw.iam.audit.events.utils.EventUtils.sanitize;
+
import it.infn.mw.iam.persistence.model.IamAccount;
public class X509CertificateUnlinkedEvent extends AccountEvent {
@@ -30,7 +32,7 @@ public class X509CertificateUnlinkedEvent extends AccountEvent {
public X509CertificateUnlinkedEvent(Object source, IamAccount account, String message,
String certificateSubject) {
super(source, account, message);
- this.certificateSubject = certificateSubject;
+ this.certificateSubject = sanitize(certificateSubject);
}
public String getCertificateSubject() {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUpdatedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUpdatedEvent.java
index fee6c70c4..2efa5d343 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUpdatedEvent.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateUpdatedEvent.java
@@ -15,6 +15,7 @@
*/
package it.infn.mw.iam.audit.events.account;
+import it.infn.mw.iam.audit.events.account.x509.X509CertificateLinkedEvent;
import it.infn.mw.iam.authn.x509.IamX509AuthenticationCredential;
import it.infn.mw.iam.persistence.model.IamAccount;
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppDisabledEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppDisabledEvent.java
new file mode 100644
index 000000000..60bbb5a47
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppDisabledEvent.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.audit.events.account.multi_factor_authentication;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+
+public class AuthenticatorAppDisabledEvent extends MultiFactorEvent {
+
+ public static final String TEMPLATE = "Authenticator app MFA disabled on account '%s'";
+
+ private static final long serialVersionUID = 1L;
+
+ public AuthenticatorAppDisabledEvent(Object source, IamAccount account, IamTotpMfa totpMfa) {
+ super(source, account, totpMfa, String.format(TEMPLATE, account.getUsername()));
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppEnabledEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppEnabledEvent.java
new file mode 100644
index 000000000..b2ecf1e4d
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/AuthenticatorAppEnabledEvent.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.audit.events.account.multi_factor_authentication;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+
+public class AuthenticatorAppEnabledEvent extends MultiFactorEvent {
+
+ public static final String TEMPLATE = "Authenticator app MFA enabled on account '%s'";
+
+ private static final long serialVersionUID = 1L;
+
+ public AuthenticatorAppEnabledEvent(Object source, IamAccount account, IamTotpMfa totpMfa) {
+ super(source, account, totpMfa, String.format(TEMPLATE, account.getUsername()));
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/MultiFactorEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/MultiFactorEvent.java
new file mode 100644
index 000000000..b22207c95
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/MultiFactorEvent.java
@@ -0,0 +1,41 @@
+/**
+ * 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 com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import it.infn.mw.iam.audit.events.account.AccountEvent;
+import it.infn.mw.iam.audit.utils.IamTotpMfaSerializer;
+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;
+
+ @JsonSerialize(using=IamTotpMfaSerializer.class)
+ private final IamTotpMfa totpMfa;
+
+ protected MultiFactorEvent(Object source, IamAccount account, IamTotpMfa totpMfa,
+ String message) {
+ super(source, account, message);
+ this.totpMfa = totpMfa;
+ }
+
+ public IamTotpMfa getTotpMfa() {
+ return totpMfa;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/TotpVerifiedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/TotpVerifiedEvent.java
new file mode 100644
index 000000000..8ff5e8c32
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/multi_factor_authentication/TotpVerifiedEvent.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.audit.events.account.multi_factor_authentication;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+
+public class TotpVerifiedEvent extends MultiFactorEvent {
+
+ public static final String TEMPLATE = "MFA TOTP verified for account '%s'";
+
+ private static final long serialVersionUID = 1L;
+
+ public TotpVerifiedEvent(Object source, IamAccount account, IamTotpMfa totpMfa) {
+ super(source, account, totpMfa, String.format(TEMPLATE, account.getUsername()));
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateLinkedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/x509/X509CertificateLinkedEvent.java
similarity index 92%
rename from iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateLinkedEvent.java
rename to iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/x509/X509CertificateLinkedEvent.java
index 0604a29c9..78d887cf0 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/X509CertificateLinkedEvent.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/x509/X509CertificateLinkedEvent.java
@@ -13,8 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package it.infn.mw.iam.audit.events.account;
+package it.infn.mw.iam.audit.events.account.x509;
+import it.infn.mw.iam.audit.events.account.AccountEvent;
import it.infn.mw.iam.authn.x509.IamX509AuthenticationCredential;
import it.infn.mw.iam.persistence.model.IamAccount;
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/x509/X509CertificateUnlinkedEvent.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/x509/X509CertificateUnlinkedEvent.java
new file mode 100644
index 000000000..f5bfae256
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/account/x509/X509CertificateUnlinkedEvent.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.audit.events.account.x509;
+
+import static it.infn.mw.iam.audit.events.utils.EventUtils.sanitize;
+
+import it.infn.mw.iam.audit.events.account.AccountEvent;
+import it.infn.mw.iam.persistence.model.IamAccount;
+
+public class X509CertificateUnlinkedEvent extends AccountEvent {
+
+ /**
+ *
+ */
+ private static final long serialVersionUID = 1L;
+
+
+ private final String certificateSubject;
+
+ public X509CertificateUnlinkedEvent(Object source, IamAccount account, String message,
+ String certificateSubject) {
+ super(source, account, message);
+ this.certificateSubject = sanitize(certificateSubject);
+ }
+
+ public String getCertificateSubject() {
+ return certificateSubject;
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/utils/EventUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/utils/EventUtils.java
new file mode 100644
index 000000000..133db514a
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/events/utils/EventUtils.java
@@ -0,0 +1,23 @@
+/**
+ * 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.utils;
+
+public class EventUtils {
+
+ public static String sanitize(String param) {
+ return param.replaceAll("[\n\r]", "_");
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/audit/utils/IamTotpMfaSerializer.java b/iam-login-service/src/main/java/it/infn/mw/iam/audit/utils/IamTotpMfaSerializer.java
new file mode 100644
index 000000000..85a91a2fb
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/audit/utils/IamTotpMfaSerializer.java
@@ -0,0 +1,40 @@
+/**
+ * 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.utils;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+
+public class IamTotpMfaSerializer extends JsonSerializer {
+
+ @Override
+ public void serialize(IamTotpMfa value, JsonGenerator gen, SerializerProvider serializers)
+ throws IOException {
+
+ gen.writeStartObject();
+ gen.writeStringField("account", value.getAccount().getUsername());
+ gen.writeStringField("creationTime", value.getCreationTime().toString());
+ gen.writeStringField("lastUpdateTime", value.getLastUpdateTime().toString());
+ gen.writeStringField("active", String.valueOf(value.isActive()));
+ gen.writeEndObject();
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java
new file mode 100644
index 000000000..eebdede7d
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/CheckMultiFactorIsEnabledSuccessHandler.java
@@ -0,0 +1,130 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
+
+import java.io.IOException;
+import java.util.Collection;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.web.WebAttributes;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
+
+import it.infn.mw.iam.api.account.AccountUtils;
+import it.infn.mw.iam.authn.util.Authorities;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.service.aup.AUPSignatureCheckService;
+
+/**
+ * Success handler for the normal login flow. This determines if MFA is enabled on an account and,
+ * if so, redirects the user to a verification page. Otherwise, the default success handler is
+ * called
+ */
+public class CheckMultiFactorIsEnabledSuccessHandler implements AuthenticationSuccessHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(CheckMultiFactorIsEnabledSuccessHandler.class);
+
+ private final AccountUtils accountUtils;
+ private final String iamBaseUrl;
+ private final AUPSignatureCheckService aupSignatureCheckService;
+ private final IamAccountRepository accountRepo;
+
+ public CheckMultiFactorIsEnabledSuccessHandler(AccountUtils accountUtils, String iamBaseUrl,
+ AUPSignatureCheckService aupSignatureCheckService, IamAccountRepository accountRepo) {
+ this.accountUtils = accountUtils;
+ this.iamBaseUrl = iamBaseUrl;
+ this.aupSignatureCheckService = aupSignatureCheckService;
+ this.accountRepo = accountRepo;
+ }
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ handle(request, response, authentication);
+ clearAuthenticationAttributes(request);
+ }
+
+ protected void handle(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ boolean isPreAuthenticated = isPreAuthenticated(authentication);
+
+ if (response.isCommitted()) {
+ logger.warn("Response has already been committed. Unable to redirect to " + MFA_VERIFY_URL);
+ } else if (isPreAuthenticated) {
+ response.sendRedirect(MFA_VERIFY_URL);
+ } else {
+ continueWithDefaultSuccessHandler(request, response, authentication);
+ }
+ }
+
+ /**
+ * If the user account is MFA enabled, the authentication provider would have assigned a role of
+ * PRE_AUTHENTICATED at this stage. This function verifies that to determine if we need
+ * redirecting to the verification page
+ *
+ * @param authentication the user authentication
+ * @return true if PRE_AUTHENTICATED
+ */
+ protected boolean isPreAuthenticated(final Authentication authentication) {
+ final Collection 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..3fba3f401
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequest.java
@@ -0,0 +1,120 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+
+/**
+ * Represents an extended {@code HttpServletRequest} object. This is primarily used for including
+ * information in an OAuth2 authorization request about the authentication method(s) used by the
+ * user to sign in. These are ultimately passed to the token endpoint so they may be included in the
+ * id_token received by the client.
+ */
+public final class ExtendedHttpServletRequest extends HttpServletRequestWrapper {
+
+ private final Map queryParameterMap;
+ private final Charset requestEncoding;
+
+ public ExtendedHttpServletRequest(HttpServletRequest request, String amrClaim) {
+ super(request);
+ Map queryMap = getCommonQueryParamFromLegacy(request.getParameterMap());
+ queryMap.put(AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING, new String[] {amrClaim});
+ queryParameterMap = Collections.unmodifiableMap(queryMap);
+
+ String encoding = request.getCharacterEncoding();
+ requestEncoding = (encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8);
+ }
+
+ private final Map getCommonQueryParamFromLegacy(
+ Map paramMap) {
+ Objects.requireNonNull(paramMap);
+
+ return new LinkedHashMap<>(paramMap);
+ }
+
+ @Override
+ public String getParameter(String name) {
+ String[] params = queryParameterMap.get(name);
+ return params != null ? params[0] : null;
+ }
+
+ @Override
+ public String[] getParameterValues(String name) {
+ return queryParameterMap.get(name);
+ }
+
+ @Override
+ public Map getParameterMap() {
+ return queryParameterMap; // unmodifiable to uphold the interface contract.
+ }
+
+ @Override
+ public Enumeration getParameterNames() {
+ return Collections.enumeration(queryParameterMap.keySet());
+ }
+
+ @Override
+ public String getQueryString() {
+ // @see : https://stackoverflow.com/a/35831692/9869013
+ // return queryParameterMap.entrySet().stream().flatMap(entry ->
+ // Stream.of(entry.getValue()).map(value -> entry.getKey() + "=" +
+ // value)).collect(Collectors.joining("&")); // without encoding !!
+ return queryParameterMap.entrySet()
+ .stream()
+ .flatMap(entry -> encodeMultiParameter(entry.getKey(), entry.getValue(), requestEncoding))
+ .collect(Collectors.joining("&"));
+ }
+
+ private Stream encodeMultiParameter(String key, String[] values, Charset encoding) {
+ return Stream.of(values).map(value -> encodeSingleParameter(key, value, encoding));
+ }
+
+ private String encodeSingleParameter(String key, String value, Charset encoding) {
+ return urlEncode(key, encoding) + "=" + urlEncode(value, encoding);
+ }
+
+ private String urlEncode(String value, Charset encoding) {
+ try {
+ return URLEncoder.encode(value, encoding.name());
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalArgumentException("Cannot url encode " + value, e);
+ }
+ }
+
+ @Override
+ public ServletInputStream getInputStream() throws IOException {
+ throw new UnsupportedOperationException("getInputStream() is not implemented in this "
+ + HttpServletRequest.class.getSimpleName() + " wrapper");
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java
new file mode 100644
index 000000000..eb567824c
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/ExtendedHttpServletRequestFilter.java
@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Set;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.GenericFilterBean;
+
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+
+/**
+ * This filter is applied after authentication has taken place. It is used in the OAuth2 process to
+ * detect if a set of {@code IamAuthenticationMethodReference} objects are included in the current
+ * {@code Authentication} object. If so, these are passed to an {@code ExtendedHttpServletRequest}
+ * so they may be included in the authorization request and passed to OAuth2 clients.
+ */
+public class ExtendedHttpServletRequestFilter extends GenericFilterBean {
+
+ public static final String AUTHORIZATION_REQUEST_INCLUDES_AMR =
+ "AUTHORIZATION_REQUEST_INCLUDES_AMR";
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ // We fetch the ExtendedAuthenticationToken from the security context. This contains the
+ // authentication method references we want to include in the authorization request
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+
+ // Checking to see if this filter has been applied already (if so, this attribute will have
+ // already been set)
+ Object amrAttribute = request.getAttribute(AUTHORIZATION_REQUEST_INCLUDES_AMR);
+
+ if (amrAttribute == null && auth instanceof ExtendedAuthenticationToken) {
+ Set amrSet =
+ ((ExtendedAuthenticationToken) auth).getAuthenticationMethodReferences();
+ String amrClaim = parseAuthenticationMethodReferences(amrSet);
+
+ ExtendedHttpServletRequest extendedRequest =
+ new ExtendedHttpServletRequest((HttpServletRequest) request, amrClaim);
+
+ extendedRequest.setAttribute(AUTHORIZATION_REQUEST_INCLUDES_AMR, Boolean.TRUE);
+ request = extendedRequest;
+ }
+
+ chain.doFilter(request, response);
+ }
+
+ /**
+ * Convert a set of authentication method references into a request parameter string Values are
+ * separated with a + symbol
+ *
+ * @param amrSet the set of authentication method references
+ * @return the parsed string
+ */
+ private String parseAuthenticationMethodReferences(Set amrSet) {
+ String amrClaim = "";
+ Iterator it = amrSet.iterator();
+ while (it.hasNext()) {
+ IamAuthenticationMethodReference current = it.next();
+ StringBuilder amrClaimBuilder = new StringBuilder(amrClaim);
+ amrClaimBuilder.append(current.getName()).append("+");
+ amrClaim = amrClaimBuilder.toString();
+ }
+
+ // Remove trailing + symbol at end of string
+ amrClaim = amrClaim.substring(0, amrClaim.length() - 1);
+ return amrClaim;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java
new file mode 100644
index 000000000..491d08afd
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/IamAuthenticationMethodReference.java
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import java.io.Serializable;
+
+public class IamAuthenticationMethodReference implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ public static final String AUTHENTICATION_METHOD_REFERENCE_CLAIM_STRING = "amr";
+
+ public enum AuthenticationMethodReferenceValues {
+ // Add additional values here if new authentication factors get added, e.g. HARDWARE_KEY("hwk")
+ // Consult here for standardised reference values -
+ // https://datatracker.ietf.org/doc/html/rfc8176
+
+ PASSWORD("pwd"), ONE_TIME_PASSWORD("otp");
+
+ private final String value;
+
+ private AuthenticationMethodReferenceValues(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+ }
+
+ private String name;
+
+ public IamAuthenticationMethodReference(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java
new file mode 100644
index 000000000..258528983
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MfaVerifyController.java
@@ -0,0 +1,92 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
+
+import java.util.Optional;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.*;
+import it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsDTO;
+import it.infn.mw.iam.api.common.ErrorDTO;
+import it.infn.mw.iam.api.common.NoSuchAccountError;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+
+/**
+ * Presents the step-up authentication page for verifying identity after successful username +
+ * password authentication. Only accessible if the user is pre-authenticated, i.e. has authenticated
+ * with username + password but not fully authenticated yet
+ */
+@Controller
+@RequestMapping(MFA_VERIFY_URL)
+public class MfaVerifyController {
+
+ public static final String MFA_VERIFY_URL = "/iam/verify";
+ final IamAccountRepository accountRepository;
+ final IamTotpMfaRepository totpMfaRepository;
+
+ public MfaVerifyController(IamAccountRepository accountRepository,
+ IamTotpMfaRepository totpMfaRepository) {
+ this.accountRepository = accountRepository;
+ this.totpMfaRepository = totpMfaRepository;
+ }
+
+ @PreAuthorize("hasRole('PRE_AUTHENTICATED')")
+ @GetMapping("")
+ public String getVerifyMfaView(Authentication authentication, ModelMap model) {
+ IamAccount account = accountRepository.findByUsername(authentication.getName())
+ .orElseThrow(() -> NoSuchAccountError.forUsername(authentication.getName()));
+ MultiFactorSettingsDTO dto = populateMfaSettings(account);
+ model.addAttribute("factors", dto.toJson());
+
+ return "iam/verify-mfa";
+ }
+
+ /**
+ * Populates a DTO containing info on which additional factors of authentication are active
+ *
+ * @param account the MFA-enabled account
+ * @return DTO with populated settings
+ */
+ private MultiFactorSettingsDTO populateMfaSettings(IamAccount account) {
+ MultiFactorSettingsDTO dto = new MultiFactorSettingsDTO();
+
+ Optional totpMfaOptional = totpMfaRepository.findByAccount(account);
+ if (totpMfaOptional.isPresent()) {
+ IamTotpMfa totpMfa = totpMfaOptional.get();
+ dto.setAuthenticatorAppActive(totpMfa.isActive());
+ } else {
+ dto.setAuthenticatorAppActive(false);
+ }
+
+ return dto;
+ }
+
+ @ResponseStatus(code = HttpStatus.BAD_REQUEST)
+ @ExceptionHandler(NoSuchAccountError.class)
+ @ResponseBody
+ public ErrorDTO handleNoSuchAccountError(NoSuchAccountError e) {
+ return ErrorDTO.fromString(e.getMessage());
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java
new file mode 100644
index 000000000..e761c5a22
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorTotpCheckProvider.java
@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.ONE_TIME_PASSWORD;
+
+import java.util.Set;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+
+/**
+ * Grants full authentication by verifying a provided MFA TOTP. Only comes into play in the step-up
+ * authentication flow.
+ */
+public class MultiFactorTotpCheckProvider implements AuthenticationProvider {
+
+ private final IamAccountRepository accountRepo;
+ private final IamTotpMfaService totpMfaService;
+
+ public MultiFactorTotpCheckProvider(IamAccountRepository accountRepo,
+ IamTotpMfaService totpMfaService) {
+ this.accountRepo = accountRepo;
+ this.totpMfaService = totpMfaService;
+ }
+
+ @Override
+ public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+ ExtendedAuthenticationToken token = (ExtendedAuthenticationToken) authentication;
+
+ String totp = token.getTotp();
+ if (totp == null) {
+ return null;
+ }
+
+ IamAccount account = accountRepo.findByUsername(authentication.getName())
+ .orElseThrow(() -> new BadCredentialsException("Invalid login details"));
+
+ boolean valid = false;
+
+ try {
+ valid = totpMfaService.verifyTotp(account, totp);
+ } catch (MfaSecretNotFoundException e) {
+ throw new MfaSecretNotFoundException("No multi-factor secret is attached to this account");
+ }
+
+ if (!valid) {
+ throw new BadCredentialsException("Bad TOTP");
+ }
+
+ return createSuccessfulAuthentication(token);
+ }
+
+ protected Authentication createSuccessfulAuthentication(ExtendedAuthenticationToken token) {
+ IamAuthenticationMethodReference otp =
+ new IamAuthenticationMethodReference(ONE_TIME_PASSWORD.getValue());
+ Set refs = token.getAuthenticationMethodReferences();
+ refs.add(otp);
+ token.setAuthenticationMethodReferences(refs);
+
+ ExtendedAuthenticationToken newToken = new ExtendedAuthenticationToken(token.getPrincipal(),
+ token.getCredentials(), token.getFullyAuthenticatedAuthorities());
+ newToken.setAuthenticationMethodReferences(token.getAuthenticationMethodReferences());
+ newToken.setAuthenticated(true);
+
+ return newToken;
+ }
+
+ @Override
+ public boolean supports(Class> authentication) {
+ return authentication.equals(ExtendedAuthenticationToken.class);
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.java
new file mode 100644
index 000000000..b2884b803
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationFilter.java
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
+
+import java.io.IOException;
+import java.nio.file.ProviderNotFoundException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+
+/**
+ * Used in the MFA verification flow. Receives either a TOTP and constructs the
+ * authentication request with this parameter. The request is passed to dedicated authentication
+ * providers which will create the full authentication or raise the appropriate exception
+ */
+public class MultiFactorVerificationFilter extends AbstractAuthenticationProcessingFilter {
+
+ public static final String TOTP_MFA_CODE_KEY = "totp";
+ public static final String TOTP_VERIFIED = "TOTP_VERIFIED";
+
+ public static final AntPathRequestMatcher DEFAULT_MFA_VERIFY_ANT_PATH_REQUEST_MATCHER =
+ new AntPathRequestMatcher(MFA_VERIFY_URL, "POST");
+
+ private static final boolean postOnly = true;
+
+ private String totpParameter = TOTP_MFA_CODE_KEY;
+
+ public MultiFactorVerificationFilter(AuthenticationManager authenticationManager,
+ AuthenticationSuccessHandler successHandler, AuthenticationFailureHandler failureHandler) {
+ super(DEFAULT_MFA_VERIFY_ANT_PATH_REQUEST_MATCHER, authenticationManager);
+ setAuthenticationSuccessHandler(successHandler);
+ setAuthenticationFailureHandler(failureHandler);
+ }
+
+ @Override
+ public Authentication attemptAuthentication(HttpServletRequest request,
+ HttpServletResponse response) throws AuthenticationException {
+ if (postOnly && !request.getMethod().equals("POST")) {
+ throw new AuthenticationServiceException(
+ "Authentication method not supported: " + request.getMethod());
+ }
+
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (!(auth instanceof ExtendedAuthenticationToken)) {
+ throw new AuthenticationServiceException("Bad authentication");
+ }
+
+ ExtendedAuthenticationToken authRequest = (ExtendedAuthenticationToken) auth;
+
+ // Parse TOTP from request (only one should be set)
+ String totp = parseTotp(request);
+
+ if (totp != null) {
+ authRequest.setTotp(totp);
+ } else {
+ throw new ProviderNotFoundException("No valid totp code was received");
+ }
+
+ Authentication fullAuthentication = this.getAuthenticationManager().authenticate(authRequest);
+ if (fullAuthentication == null) {
+ throw new ProviderNotFoundException("No valid totp code was received");
+ }
+
+ if (authRequest.getTotp() != null) {
+ request.setAttribute(TOTP_VERIFIED, Boolean.TRUE);
+ }
+
+ return fullAuthentication;
+ }
+
+ /**
+ * Overriding default method because we don't want to invalidate authentication. Doing so would
+ * remove our PRE_AUTHENTICATED role, which would kick us out of the verification process
+ */
+ @Override
+ protected void unsuccessfulAuthentication(HttpServletRequest request,
+ HttpServletResponse response, AuthenticationException failed)
+ throws IOException, ServletException {
+ this.logger.trace("Failed to process authentication request", failed);
+ this.logger.trace("Handling authentication failure");
+ this.getRememberMeServices().loginFail(request, response);
+ this.getFailureHandler().onAuthenticationFailure(request, response, failed);
+ }
+
+ private String parseTotp(HttpServletRequest request) {
+ String totp = request.getParameter(this.totpParameter);
+ return totp != null ? totp.trim() : null;
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java
new file mode 100644
index 000000000..55b850282
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/multi_factor_authentication/MultiFactorVerificationSuccessHandler.java
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.authn.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
+import static it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter.TOTP_VERIFIED;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.WebAttributes;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
+
+import it.infn.mw.iam.api.account.AccountUtils;
+import it.infn.mw.iam.authn.EnforceAupSignatureSuccessHandler;
+import it.infn.mw.iam.authn.RootIsDashboardSuccessHandler;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.service.aup.AUPSignatureCheckService;
+
+public class MultiFactorVerificationSuccessHandler implements AuthenticationSuccessHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(MultiFactorVerificationSuccessHandler.class);
+
+ private final AccountUtils accountUtils;
+ private final AUPSignatureCheckService aupSignatureCheckService;
+ private final IamAccountRepository accountRepo;
+ private final String iamBaseUrl;
+
+ public MultiFactorVerificationSuccessHandler(AccountUtils accountUtils,
+ AUPSignatureCheckService aupSignatureCheckService, IamAccountRepository accountRepo,
+ String iamBaseUrl) {
+ this.accountUtils = accountUtils;
+ this.aupSignatureCheckService = aupSignatureCheckService;
+ this.accountRepo = accountRepo;
+ this.iamBaseUrl = iamBaseUrl;
+ }
+
+ @Override
+ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ handle(request, response, authentication);
+ clearAuthenticationAttributes(request);
+ }
+
+ private void handle(HttpServletRequest request, HttpServletResponse response,
+ Authentication authentication) throws IOException, ServletException {
+ if (response.isCommitted()) {
+ logger.warn("Response has already been committed. Unable to redirect to " + MFA_VERIFY_URL);
+ } else {
+ continueWithDefaultSuccessHandler(request, response, authentication);
+ }
+ }
+
+ private void continueWithDefaultSuccessHandler(HttpServletRequest request,
+ HttpServletResponse response, Authentication auth) throws IOException, ServletException {
+
+ AuthenticationSuccessHandler delegate =
+ new RootIsDashboardSuccessHandler(iamBaseUrl, new HttpSessionRequestCache());
+
+ EnforceAupSignatureSuccessHandler handler = new EnforceAupSignatureSuccessHandler(delegate,
+ aupSignatureCheckService, accountUtils, accountRepo);
+ handler.onAuthenticationSuccess(request, response, auth);
+ }
+
+ protected void clearAuthenticationAttributes(HttpServletRequest request) {
+ HttpSession session = request.getSession(false);
+ if (session == null) {
+ return;
+ }
+ session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
+ request.removeAttribute(TOTP_VERIFIED);
+ }
+}
+
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/oidc/DefaultRestTemplateFactory.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/oidc/DefaultRestTemplateFactory.java
index cc7253ef3..e350ff221 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/authn/oidc/DefaultRestTemplateFactory.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/oidc/DefaultRestTemplateFactory.java
@@ -20,7 +20,7 @@
public class DefaultRestTemplateFactory implements RestTemplateFactory {
- private ClientHttpRequestFactory httpRequestFactory;
+ final ClientHttpRequestFactory httpRequestFactory;
public DefaultRestTemplateFactory(ClientHttpRequestFactory httpRequestFactory) {
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/IamConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java
index 7aafae459..724b90615 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java
@@ -49,6 +49,7 @@
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
+import org.springframework.session.web.http.DefaultCookieSerializer;
import com.google.common.collect.Maps;
@@ -309,8 +310,15 @@ UsernameValidator usernameRegExpValidator() {
}
@Bean(destroyMethod = "shutdown")
- public ScheduledExecutorService taskScheduler() {
+ ScheduledExecutorService taskScheduler() {
return Executors.newSingleThreadScheduledExecutor();
}
+ @Bean
+ DefaultCookieSerializer defaultCookieSerializer() {
+ DefaultCookieSerializer cs = new DefaultCookieSerializer();
+ cs.setSameSite(null);
+ return cs;
+ }
+
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java
index c6dd8f671..e4ec96528 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java
@@ -28,22 +28,18 @@
import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType;
import it.infn.mw.iam.config.login.LoginButtonProperties;
+import it.infn.mw.iam.config.multi_factor_authentication.VerifyButtonProperties;
@Component
@ConfigurationProperties(prefix = "iam")
public class IamProperties {
public enum EditableFields {
- NAME,
- SURNAME,
- EMAIL,
- PICTURE
+ NAME, SURNAME, EMAIL, PICTURE
}
public enum LocalAuthenticationAllowedUsers {
- ALL,
- VO_ADMINS,
- NONE
+ ALL, VO_ADMINS, NONE
}
public enum LoginPageLayoutOptions {
@@ -52,9 +48,7 @@ public enum LoginPageLayoutOptions {
}
public enum LocalAuthenticationLoginPageMode {
- VISIBLE,
- HIDDEN,
- HIDDEN_WITH_LINK
+ VISIBLE, HIDDEN, HIDDEN_WITH_LINK
}
public static class AccountLinkingProperties {
@@ -612,6 +606,8 @@ public void setEnrollment(String enrollment) {
private LoginButtonProperties loginButton = new LoginButtonProperties();
+ private VerifyButtonProperties verifyButton = new VerifyButtonProperties();
+
private RegistractionAccessToken token = new RegistractionAccessToken();
private PrivacyPolicy privacyPolicy = new PrivacyPolicy();
@@ -718,6 +714,14 @@ public void setLoginButton(LoginButtonProperties loginButton) {
this.loginButton = loginButton;
}
+ public VerifyButtonProperties getVerifyButton() {
+ return verifyButton;
+ }
+
+ public void setVerifyButton(VerifyButtonProperties verifyButton) {
+ this.verifyButton = verifyButton;
+ }
+
public void setPrivacyPolicy(PrivacyPolicy privacyPolicy) {
this.privacyPolicy = privacyPolicy;
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java
new file mode 100644
index 000000000..f760a760a
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamTotpMfaConfig.java
@@ -0,0 +1,142 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.config;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
+
+import java.util.Arrays;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
+
+import dev.samstevens.totp.code.CodeVerifier;
+import dev.samstevens.totp.code.DefaultCodeGenerator;
+import dev.samstevens.totp.code.DefaultCodeVerifier;
+import dev.samstevens.totp.qr.QrGenerator;
+import dev.samstevens.totp.qr.ZxingPngQrGenerator;
+import dev.samstevens.totp.secret.DefaultSecretGenerator;
+import dev.samstevens.totp.secret.SecretGenerator;
+import dev.samstevens.totp.time.SystemTimeProvider;
+import it.infn.mw.iam.api.account.AccountUtils;
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.service.aup.AUPSignatureCheckService;
+import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorTotpCheckProvider;
+import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter;
+import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationSuccessHandler;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+
+/**
+ * Beans for handling TOTP MFA functionality
+ */
+@Configuration
+public class IamTotpMfaConfig {
+
+ @Value("${iam.baseUrl}")
+ private String iamBaseUrl;
+
+ @Autowired
+ private IamAccountRepository accountRepo;
+
+ @Autowired
+ private AUPSignatureCheckService aupSignatureCheckService;
+
+ @Autowired
+ private AccountUtils accountUtils;
+
+ /**
+ * Responsible for generating new TOTP secrets
+ *
+ * @return SecretGenerator
+ */
+ @Bean
+ @Qualifier("secretGenerator")
+ SecretGenerator secretGenerator() {
+ return new DefaultSecretGenerator();
+ }
+
+
+ /**
+ * Responsible for generating QR code data URI strings from given input parameters, e.g. TOTP
+ * secret, issuer, etc.
+ *
+ * @return QrGenerator
+ */
+ @Bean
+ @Qualifier("qrGenerator")
+ QrGenerator qrGenerator() {
+ return new ZxingPngQrGenerator();
+ }
+
+
+ /**
+ * Generates a TOTP from an MFA secret and verifies a user-provided TOTP matches it
+ *
+ * @return CodeVerifier
+ */
+ @Bean
+ @Qualifier("codeVerifier")
+ CodeVerifier codeVerifier() {
+ return new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider());
+ }
+
+ @Bean(name = "MultiFactorVerificationFilter")
+ MultiFactorVerificationFilter multiFactorVerificationFilter(
+ @Qualifier("MultiFactorVerificationAuthenticationManager") AuthenticationManager authenticationManager) {
+
+ return new MultiFactorVerificationFilter(authenticationManager, successHandler(),
+ failureHandler());
+ }
+
+ /**
+ * Authentication manager for the MFA verification process
+ *
+ * @param totpCheckProvider checks a provided TOTP
+ * @return a new provider manager
+ */
+ @Bean(name = "MultiFactorVerificationAuthenticationManager")
+ AuthenticationManager authenticationManager(MultiFactorTotpCheckProvider totpCheckProvider) {
+ return new ProviderManager(Arrays.asList(totpCheckProvider));
+ }
+
+ public AuthenticationSuccessHandler successHandler() {
+ return new MultiFactorVerificationSuccessHandler(accountUtils, aupSignatureCheckService,
+ accountRepo, iamBaseUrl);
+ }
+
+ /**
+ * If we can't verify the user in step-up authentication, redirect back to the /verify endpoint
+ * with an error param
+ *
+ * @return failure handler to redirect to /verify endpoint
+ */
+ public AuthenticationFailureHandler failureHandler() {
+ return new SimpleUrlAuthenticationFailureHandler(MFA_VERIFY_URL + "?error=failure");
+ }
+
+ @Bean
+ MultiFactorTotpCheckProvider totpCheckProvider(IamTotpMfaService totpMfaService) {
+ return new MultiFactorTotpCheckProvider(accountRepo, totpMfaService);
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java
index 0d7667694..f1cb59891 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/cern/CernProperties.java
@@ -17,8 +17,8 @@
import javax.validation.Valid;
import javax.validation.constraints.Min;
-
import javax.validation.constraints.NotBlank;
+
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
@@ -154,4 +154,5 @@ public HrSynchTaskProperties getTask() {
public void setTask(HrSynchTaskProperties task) {
this.task = task;
}
+
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/IamTotpMfaProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/IamTotpMfaProperties.java
new file mode 100644
index 000000000..3168c99e4
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/mfa/IamTotpMfaProperties.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.config.mfa;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ConfigurationProperties(prefix = "mfa")
+public class IamTotpMfaProperties {
+
+ private boolean multiFactorSettingsBtnEnabled;
+ private String passwordToEncryptAndDecrypt;
+
+ public String getPasswordToEncryptOrDecrypt() {
+ return passwordToEncryptAndDecrypt;
+ }
+
+ public void setPasswordToEncryptAndDecrypt(String passwordToEncryptAndDecrypt) {
+ this.passwordToEncryptAndDecrypt = passwordToEncryptAndDecrypt;
+ }
+
+ public void setMultiFactorSettingsBtnEnabled(boolean multiFactorSettingsBtnEnabled) {
+ this.multiFactorSettingsBtnEnabled = multiFactorSettingsBtnEnabled;
+ }
+
+ public boolean hasMultiFactorSettingsBtnEnabled() {
+ return multiFactorSettingsBtnEnabled;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/multi_factor_authentication/VerifyButtonProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/multi_factor_authentication/VerifyButtonProperties.java
new file mode 100644
index 000000000..24dca1e20
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/multi_factor_authentication/VerifyButtonProperties.java
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.config.multi_factor_authentication;
+
+import javax.validation.constraints.NotBlank;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+
+/**
+ * Verify button that appears on the MFA verification page
+ */
+@JsonInclude(Include.NON_EMPTY)
+public class VerifyButtonProperties {
+ private String text;
+
+ private String title;
+
+ @NotBlank
+ private String style = "btn-verify";
+
+ private boolean visible = true;
+
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+
+ public String getStyle() {
+ return style;
+ }
+
+ public void setStyle(String style) {
+ this.style = style;
+ }
+
+ public boolean isVisible() {
+ return visible;
+ }
+
+ public void setVisible(boolean visible) {
+ this.visible = visible;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/oidc/OidcConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/oidc/OidcConfiguration.java
index 53edba2c5..81b0ba6db 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/oidc/OidcConfiguration.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/oidc/OidcConfiguration.java
@@ -94,7 +94,7 @@ public class OidcConfiguration {
public static final String DEFINE_ME_PLEASE = "define_me_please";
@Bean
- public FilterRegistrationBean disabledAutomaticOidcFilterRegistration(
+ FilterRegistrationBean disabledAutomaticOidcFilterRegistration(
OidcClientFilter f) {
FilterRegistrationBean b = new FilterRegistrationBean<>(f);
@@ -103,7 +103,7 @@ public FilterRegistrationBean disabledAutomaticOidcFilterRegis
}
@Bean(name = "OIDCAuthenticationFilter")
- public OidcClientFilter openIdConnectAuthenticationFilterCanl(OidcTokenRequestor tokenRequestor,
+ OidcClientFilter openIdConnectAuthenticationFilterCanl(OidcTokenRequestor tokenRequestor,
@Qualifier("OIDCAuthenticationManager") AuthenticationManager oidcAuthenticationManager,
@Qualifier("OIDCExternalAuthenticationSuccessHandler") AuthenticationSuccessHandler successHandler,
@Qualifier("OIDCExternalAuthenticationFailureHandler") AuthenticationFailureHandler failureHandler,
@@ -129,27 +129,27 @@ public OidcClientFilter openIdConnectAuthenticationFilterCanl(OidcTokenRequestor
@Bean
@Profile("!canl")
- public RestTemplateFactory restTemplateFactory() {
+ RestTemplateFactory restTemplateFactory() {
return new DefaultRestTemplateFactory(new HttpComponentsClientHttpRequestFactory());
}
@Bean
@Profile("canl")
- public RestTemplateFactory canlRestTemplateFactory(
+ RestTemplateFactory canlRestTemplateFactory(
@Qualifier("canlRequestFactory") ClientHttpRequestFactory rf) {
return new DefaultRestTemplateFactory(rf);
}
@Bean(name = "OIDCExternalAuthenticationFailureHandler")
- public AuthenticationFailureHandler failureHandler() {
+ AuthenticationFailureHandler failureHandler() {
return new ExternalAuthenticationFailureHandler(new OidcExceptionMessageHelper());
}
@Bean(name = "OIDCExternalAuthenticationSuccessHandler")
- public AuthenticationSuccessHandler successHandler() {
+ AuthenticationSuccessHandler successHandler() {
RootIsDashboardSuccessHandler sa =
new RootIsDashboardSuccessHandler(iamBaseUrl, new HttpSessionRequestCache());
@@ -161,13 +161,13 @@ public AuthenticationSuccessHandler successHandler() {
}
@Bean(name = "OIDCAuthenticationManager")
- public AuthenticationManager authenticationManager(
+ AuthenticationManager authenticationManager(
OIDCAuthenticationProvider oidcAuthenticationProvider) {
return new ProviderManager(Arrays.asList(oidcAuthenticationProvider));
}
@Bean
- public OIDCAuthenticationProvider openIdConnectAuthenticationProvider(Clock clock,
+ OIDCAuthenticationProvider openIdConnectAuthenticationProvider(Clock clock,
OidcUserDetailsService userDetailService, UserInfoFetcher userInfoFetcher,
AuthenticationValidator validator, SessionTimeoutHelper timeoutHelper) {
@@ -180,21 +180,21 @@ public OIDCAuthenticationProvider openIdConnectAuthenticationProvider(Clock cloc
}
@Bean
- public IssuerService oidcIssuerService() {
+ IssuerService oidcIssuerService() {
return new IamThirdPartyIssuerService();
}
@Bean
@Profile("!canl")
- public ServerConfigurationService dynamicServerConfiguration() {
+ ServerConfigurationService dynamicServerConfiguration() {
return new DynamicServerConfigurationService();
}
@Bean
@Profile("canl")
- public ServerConfigurationService canlDynamicServerConfiguration(
+ ServerConfigurationService canlDynamicServerConfiguration(
@Qualifier("canlHttpClient") HttpClient client) {
return new DynamicServerConfigurationService(client);
@@ -205,7 +205,7 @@ public boolean configuredProvider(OidcProvider provider) {
}
@Bean
- public ClientConfigurationService oidcClientConfiguration(OidcValidatedProviders providers) {
+ ClientConfigurationService oidcClientConfiguration(OidcValidatedProviders providers) {
Map clients = new LinkedHashMap<>();
@@ -231,31 +231,31 @@ public ClientConfigurationService oidcClientConfiguration(OidcValidatedProviders
}
@Bean
- public AuthRequestOptionsService authOptions() {
+ AuthRequestOptionsService authOptions() {
return new StaticAuthRequestOptionsService();
}
@Bean
- public AuthRequestUrlBuilder authRequestBuilder() {
+ AuthRequestUrlBuilder authRequestBuilder() {
return new PlainAuthRequestUrlBuilder();
}
@Bean
- public OidcUserDetailsService userDetailService(IamAccountRepository repo,
+ OidcUserDetailsService userDetailService(IamAccountRepository repo,
InactiveAccountAuthenticationHander handler) {
return new DefaultOidcUserDetailsService(repo, handler);
}
@Bean
- public UserInfoFetcher userInfoFetcher() {
+ UserInfoFetcher userInfoFetcher() {
return new UserInfoFetcher();
}
@Bean
- public OidcTokenRequestor tokenRequestor(RestTemplateFactory restTemplateFactory,
+ OidcTokenRequestor tokenRequestor(RestTemplateFactory restTemplateFactory,
ObjectMapper mapper) {
return new DefaultOidcTokenRequestor(restTemplateFactory, mapper);
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamApiSecurityConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamApiSecurityConfig.java
index 4fa7c1c3b..b5289d7be 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamApiSecurityConfig.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamApiSecurityConfig.java
@@ -16,6 +16,7 @@
package it.infn.mw.iam.config.security;
import static org.springframework.http.HttpMethod.GET;
+import static org.springframework.http.HttpMethod.HEAD;
import static org.springframework.http.HttpMethod.POST;
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,6 +40,7 @@
import it.infn.mw.iam.api.proxy.ProxyCertificatesApiController;
import it.infn.mw.iam.config.IamProperties;
+import it.infn.mw.iam.config.security.IamWebSecurityConfig.UserLoginConfig;
import it.infn.mw.iam.core.oauth.FormClientCredentialsAuthenticationFilter;
@SuppressWarnings("deprecation")
@@ -90,7 +92,7 @@ protected void configure(final HttpSecurity http) throws Exception {
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
- .anyRequest().fullyAuthenticated()
+ .anyRequest().fullyAuthenticated()
.and()
.csrf().disable();
// @formatter:on
@@ -99,6 +101,31 @@ protected void configure(final HttpSecurity http) throws Exception {
@Configuration
@Order(21)
+ public static class IamApiRedirectConfig extends WebSecurityConfigurerAdapter {
+
+ @Autowired
+ private OAuth2AuthenticationProcessingFilter resourceFilter;
+
+ @Autowired
+ private UserLoginConfig userLoginConfig;
+
+ @Override
+ protected void configure(final HttpSecurity http) throws Exception {
+
+ http.requestMatchers(matchers -> matchers.antMatchers("/iam/aup/sign"))
+ .exceptionHandling(
+ handling -> handling.authenticationEntryPoint(userLoginConfig.entryPoint())
+ .accessDeniedHandler(new OAuth2AccessDeniedHandler()))
+ .addFilterAfter(resourceFilter, SecurityContextPersistenceFilter.class)
+ .sessionManagement(
+ management -> management.sessionCreationPolicy(SessionCreationPolicy.NEVER))
+ .authorizeRequests(requests -> requests.anyRequest().authenticated())
+ .csrf(csrf -> csrf.disable());
+ }
+ }
+
+ @Configuration
+ @Order(22)
public static class IamApiConfig extends WebSecurityConfigurerAdapter {
private static final String AUP_PATH = "/iam/aup";
@@ -134,8 +161,9 @@ protected void configure(final HttpSecurity http) throws Exception {
.antMatchers(GET, "/registration/username-available/**").permitAll()
.antMatchers(GET, "/registration/email-available/**").permitAll()
.antMatchers(GET, "/registration/config").permitAll()
- .antMatchers(GET, "/registration/confirm/**").permitAll()
+ .antMatchers(HEAD, "/registration/verify/**").permitAll()
.antMatchers(GET, "/registration/verify/**").permitAll()
+ .antMatchers(POST, "/registration/verify").permitAll()
.antMatchers(GET, "/registration/submitted").permitAll()
.antMatchers(GET, "/iam/config/**").permitAll()
.antMatchers(GET, AUP_PATH).permitAll()
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java
index b9eeaa52d..66b7bdb63 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java
@@ -17,6 +17,7 @@
import static it.infn.mw.iam.authn.ExternalAuthenticationHandlerSupport.EXT_AUTHN_UNREGISTERED_USER_AUTH;
import static it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo.ExternalAuthenticationType.OIDC;
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
import javax.servlet.RequestDispatcher;
@@ -41,18 +42,22 @@
import org.springframework.security.oauth2.provider.expression.OAuth2WebSecurityExpressionHandler;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
+import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
-import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.filter.GenericFilterBean;
import it.infn.mw.iam.api.account.AccountUtils;
-import it.infn.mw.iam.authn.EnforceAupSignatureSuccessHandler;
+import it.infn.mw.iam.authn.CheckMultiFactorIsEnabledSuccessHandler;
import it.infn.mw.iam.authn.ExternalAuthenticationHintService;
import it.infn.mw.iam.authn.HintAwareAuthenticationEntryPoint;
-import it.infn.mw.iam.authn.RootIsDashboardSuccessHandler;
+import it.infn.mw.iam.authn.multi_factor_authentication.ExtendedAuthenticationFilter;
+import it.infn.mw.iam.authn.multi_factor_authentication.ExtendedHttpServletRequestFilter;
+import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter;
import it.infn.mw.iam.authn.oidc.OidcAuthenticationProvider;
import it.infn.mw.iam.authn.oidc.OidcClientFilter;
import it.infn.mw.iam.authn.x509.IamX509AuthenticationProvider;
@@ -62,14 +67,13 @@
import it.infn.mw.iam.config.IamProperties;
import it.infn.mw.iam.core.IamLocalAuthenticationProvider;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
import it.infn.mw.iam.service.aup.AUPSignatureCheckService;
@SuppressWarnings("deprecation")
@Configuration
@EnableWebSecurity
public class IamWebSecurityConfig {
-
-
@Bean
public SecurityEvaluationContextExtension contextExtension() {
@@ -105,6 +109,9 @@ public static class UserLoginConfig extends WebSecurityConfigurerAdapter {
@Autowired
private IamAccountRepository accountRepo;
+
+ @Autowired
+ private IamTotpMfaRepository totpMfaRepository;
@Autowired
private AUPSignatureCheckService aupSignatureCheckService;
@@ -121,7 +128,7 @@ public static class UserLoginConfig extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(final AuthenticationManagerBuilder auth) throws Exception {
// @formatter:off
- auth.authenticationProvider(new IamLocalAuthenticationProvider(iamProperties, iamUserDetailsService, passwordEncoder));
+ auth.authenticationProvider(new IamLocalAuthenticationProvider(iamProperties, iamUserDetailsService, passwordEncoder, accountRepo, totpMfaRepository));
// @formatter:on
}
@@ -175,6 +182,12 @@ protected void configure(final HttpSecurity http) throws Exception {
.authenticationEntryPoint(entryPoint())
.and()
.addFilterBefore(authorizationRequestFilter, SecurityContextPersistenceFilter.class)
+
+ // Need to replace the UsernamePasswordAuthenticationFilter because we are now making use of the ExtendedAuthenticationToken globally
+ .addFilterAt(extendedAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
+
+ // Applied in the OAuth2 login flow
+ .addFilterAfter(extendedHttpServletRequestFilter(), UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutUrl("/logout")
.and().anonymous()
@@ -190,19 +203,29 @@ public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler() {
return new OAuth2WebSecurityExpressionHandler();
}
+ public ExtendedAuthenticationFilter extendedAuthenticationFilter() throws Exception {
+ return new ExtendedAuthenticationFilter(this.authenticationManager(), successHandler(),
+ failureHandler());
+ }
+
+ public ExtendedHttpServletRequestFilter extendedHttpServletRequestFilter() {
+ return new ExtendedHttpServletRequestFilter();
+ }
+
public AuthenticationSuccessHandler successHandler() {
- AuthenticationSuccessHandler delegate =
- new RootIsDashboardSuccessHandler(iamBaseUrl, new HttpSessionRequestCache());
+ return new CheckMultiFactorIsEnabledSuccessHandler(accountUtils, iamBaseUrl,
+ aupSignatureCheckService, accountRepo);
+ }
- return new EnforceAupSignatureSuccessHandler(delegate, aupSignatureCheckService, accountUtils,
- accountRepo);
+ public AuthenticationFailureHandler failureHandler() {
+ return new SimpleUrlAuthenticationFailureHandler("/login?error=failure");
}
}
@Configuration
@Order(101)
public static class RegistrationConfig extends WebSecurityConfigurerAdapter {
-
+
public static final String START_REGISTRATION_ENDPOINT = "/start-registration";
@Autowired
@@ -326,4 +349,37 @@ public void configure(final WebSecurity builder) throws Exception {
builder.debug(true);
}
}
+
+ /**
+ * Configure the login flow for the step-up authentication. This takes place at the /iam/verify
+ * endpoint
+ */
+ @Configuration
+ @Order(102)
+ public static class MultiFactorConfigurationAdapter extends WebSecurityConfigurerAdapter {
+
+ @Autowired
+ @Qualifier("MultiFactorVerificationFilter")
+ private MultiFactorVerificationFilter multiFactorVerificationFilter;
+
+ public AuthenticationEntryPoint mfaAuthenticationEntryPoint() {
+ return new LoginUrlAuthenticationEntryPoint(MFA_VERIFY_URL);
+ }
+
+ @Override
+ protected void configure(HttpSecurity http) throws Exception {
+ http.antMatcher(MFA_VERIFY_URL + "**")
+ .authorizeRequests()
+ .anyRequest()
+ .hasRole("PRE_AUTHENTICATED")
+ .and()
+ .formLogin()
+ .failureUrl(MFA_VERIFY_URL + "?error=failure")
+ .and()
+ .exceptionHandling()
+ .authenticationEntryPoint(mfaAuthenticationEntryPoint())
+ .and()
+ .addFilterAt(multiFactorVerificationFilter, UsernamePasswordAuthenticationFilter.class);
+ }
+ }
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java
index 62abcacbe..d84606927 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/MitreSecurityConfig.java
@@ -119,7 +119,6 @@ public static class RegisterEndpointAuthorizationConfig extends WebSecurityConfi
@Autowired
private OAuth2AuthenticationEntryPoint authenticationEntryPoint;
-
@Override
public void configure(final HttpSecurity http) throws Exception {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java
new file mode 100644
index 000000000..ba01c9e49
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/ExtendedAuthenticationToken.java
@@ -0,0 +1,145 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference;
+
+/**
+ *
+ * An extended auth token that functions the same as a {@code UsernamePasswordAuthenticationToken}
+ * but with some additional fields detailing more information about the methods of authentication
+ * used.
+ *
+ *
+ * The additional information includes:
+ *
+ *
+ *
{@code Set
+ *
{@code String totp} - if authenticating with a TOTP, this field is set
+ *
{@code fullyAuthenticatedAuthorities} - the authorities the user will be granted if full
+ * authentication takes place. If an MFA user has only authenticated with a username and password so
+ * far, they will only officially have an authority of PRE_AUTHENTICATED
+ *
+ * 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/expression/IamSecurityExpressionMethods.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/expression/IamSecurityExpressionMethods.java
index 3b8f7b61b..062bba70a 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/expression/IamSecurityExpressionMethods.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/expression/IamSecurityExpressionMethods.java
@@ -67,16 +67,14 @@ public enum Role {
}
public boolean isGroupManager(String groupUuid) {
- boolean groupManager = authentication.getAuthorities()
+ return authentication.getAuthorities()
.stream()
.anyMatch(a -> a.getAuthority().equals(ROLE_GM + groupUuid));
- return groupManager && isRequestWithoutToken();
}
public boolean isUser(String userUuid) {
Optional account = accountUtils.getAuthenticatedUserAccount();
- return account.isPresent() && account.get().getUuid().equals(userUuid)
- && isRequestWithoutToken();
+ return account.isPresent() && account.get().getUuid().equals(userUuid);
}
public boolean canManageGroupRequest(String requestId) {
@@ -105,8 +103,7 @@ public boolean userCanDeleteGroupRequest(String requestId) {
public boolean hasScope(String scope) {
- if (authentication instanceof OAuth2Authentication) {
- OAuth2Authentication oauth = (OAuth2Authentication) authentication;
+ if (authentication instanceof OAuth2Authentication oauth) {
return scopeResolver.resolveScope(oauth).stream().anyMatch(s -> s.equals(scope));
}
return false;
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java
index 2699cef7a..61cdc9585 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleHandler.java
@@ -18,21 +18,15 @@
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL;
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus.PENDING_REMOVAL;
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus.SUSPENDED;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.ERROR;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.EXPIRED;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.IGNORED;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.MEMBER;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.NOT_FOUND;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_SKIP_EMAIL_SYNCH;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_SKIP_END_DATE_SYNCH;
import static java.lang.String.format;
-import java.time.Clock;
-import java.time.Instant;
import java.util.Date;
import java.util.List;
-import java.util.Objects;
import java.util.Optional;
-import org.joda.time.DateTimeComparator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
@@ -42,13 +36,14 @@
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Component;
+import org.springframework.util.Assert;
import com.google.common.collect.Lists;
import it.infn.mw.iam.api.registration.cern.CernHrDBApiService;
+import it.infn.mw.iam.api.registration.cern.CernHrDbApiError;
import it.infn.mw.iam.api.registration.cern.dto.ParticipationDTO;
import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO;
-import it.infn.mw.iam.api.scim.exception.IllegalArgumentException;
import it.infn.mw.iam.config.cern.CernProperties;
import it.infn.mw.iam.core.user.IamAccountService;
import it.infn.mw.iam.core.user.exception.EmailAlreadyBoundException;
@@ -64,11 +59,11 @@ public class CernHrLifecycleHandler implements Runnable, SchedulingConfigurer {
"Account has not the mandatory CERN person id label";
public static final String IGNORE_MESSAGE = "Skipping account as requested by the 'ignore' label";
- public static final String RESTORED_MESSAGE = "Account restored on %s";
+ public static final String NO_PERSON_FOUND_MESSAGE = "No person id %s found on HR DB";
public static final String NO_PARTICIPATION_MESSAGE =
"Account end-time not updated: no participation to %s found";
- public static final String EXPIRED_MESSAGE = "Account participation to the experiment is expired";
- public static final String VALID_MESSAGE = "Account has a valid participation to the experiment";
+ public static final String SYNCHRONIZED_MESSAGE =
+ "Account's membership to the experiment synchronized";
public static final String HR_DB_API_ERROR = "Account not updated: HR DB error";
@@ -79,139 +74,66 @@ public class CernHrLifecycleHandler implements Runnable, SchedulingConfigurer {
public static final Logger LOG = LoggerFactory.getLogger(CernHrLifecycleHandler.class);
- public enum Status {
- MEMBER, EXPIRED, IGNORED, ERROR, NOT_FOUND
- }
-
- public static final String LABEL_CERN_PREFIX = "hr.cern";
- public static final String LABEL_STATUS = "status";
- public static final String LABEL_TIMESTAMP = "timestamp";
- public static final String LABEL_MESSAGE = "message";
- public static final String LABEL_ACTION = "action";
- public static final String LABEL_IGNORE = "ignore";
- public static final String LABEL_SKIP_EMAIL_SYNCH = "skip-email-synch";
-
- private final Clock clock;
private final CernProperties cernProperties;
private final IamAccountRepository accountRepo;
private final IamAccountService accountService;
private final CernHrDBApiService hrDb;
- public CernHrLifecycleHandler(Clock clock, CernProperties cernProperties,
- IamAccountRepository accountRepo, IamAccountService accountService, CernHrDBApiService hrDb) {
- this.clock = clock;
+ public CernHrLifecycleHandler(CernProperties cernProperties, IamAccountRepository accountRepo,
+ IamAccountService accountService, CernHrDBApiService hrDb) {
this.cernProperties = cernProperties;
this.accountRepo = accountRepo;
this.accountService = accountService;
this.hrDb = hrDb;
}
- private void syncAccountInformation(IamAccount a, VOPersonDTO p) {
-
- LOG.debug("Syncing IAM account '{}' with CERN HR record id '{}'", a.getUsername(), p.getId());
-
- LOG.debug("Updating Given Name for {} to {} ...", a.getUsername(), p.getFirstName());
- accountService.setAccountGivenName(a, p.getFirstName());
- LOG.debug("Updating Family Name for {} to {} ...", a.getUsername(), p.getName());
- accountService.setAccountFamilyName(a, p.getName());
+ public void handleAccount(String cernPersonId, String experiment, IamAccount a) {
- Optional skipEmailSyncLabel =
- a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_SKIP_EMAIL_SYNCH);
+ LOG.debug("Handling IAM account (username: {} , uuid: {})", a.getUsername(), a.getUuid());
+ LOG.debug("Synchronize with CERN person id {} ({})", cernPersonId, experiment);
- if (skipEmailSyncLabel.isPresent()) {
- if (LOG.isDebugEnabled()) {
- LOG.debug("Skipping email synchronization for '{}': label '{}' is present", a.getUsername(),
- skipEmailSyncLabel.get().qualifiedName());
- }
- } else {
- LOG.debug("Updating Email for {} to {} ...", a.getUsername(), p.getEmail());
- try {
- accountService.setAccountEmail(a, p.getEmail());
- } catch (EmailAlreadyBoundException e) {
- LOG.error(e.getMessage());
- }
- }
- }
-
- private boolean accountWasSuspendedByIamLifecycleJob(IamAccount a) {
- Optional statusLabel = a.getLabelByName(LIFECYCLE_STATUS_LABEL);
- return statusLabel.isPresent() && SUSPENDED_STATUSES.contains(statusLabel.get().getValue());
- }
-
- private String getCernPersonId(IamAccount a) {
- Optional cernPersonIdLabel =
- a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, cernProperties.getPersonIdClaim());
- if (cernPersonIdLabel.isEmpty()) {
- LOG.error("Account '{}' should have CERN person id label set!", a.getUsername());
- throw new IllegalArgumentException(INVALID_ACCOUNT_MESSAGE);
- }
- return cernPersonIdLabel.get().getValue();
- }
+ deleteDeprecatedLabels(a);
- public void handleAccount(IamAccount account) {
-
- LOG.debug("Handling account: {}", account);
- Instant checkTime = clock.instant();
- String cernPersonId = getCernPersonId(account);
- String experimentName = cernProperties.getExperimentName();
- LOG.debug("Account CERN person id {} for experiment {}", cernPersonId, experimentName);
-
- accountService.deleteLabel(account, buildCernTimestampLabel());
- accountService.deleteLabel(account, buildCernActionLabel());
-
- if (account.hasLabel(buildCernIgnoreLabel())) {
- accountService.addLabel(account, buildCernStatusLabel(IGNORED));
- accountService.addLabel(account, buildCernMessageLabel(IGNORE_MESSAGE));
+ if (CernHrLifecycleUtils.isAccountIgnored(a)) {
+ setCernStatusLabel(a, CernStatus.IGNORED, IGNORE_MESSAGE);
return;
}
Optional voPerson = Optional.empty();
try {
- voPerson = Optional.ofNullable(hrDb.getHrDbPersonRecord(cernPersonId));
- } catch (RuntimeException e) {
+ voPerson = hrDb.getHrDbPersonRecord(cernPersonId);
+ } catch (CernHrDbApiError e) {
LOG.error("Error contacting HR DB api: {}", e.getMessage(), e);
+ setCernStatusLabel(a, CernStatus.ERROR, format(HR_DB_API_ERROR));
+ return;
}
- if (Objects.isNull(voPerson) || voPerson.isEmpty()) {
- accountService.addLabel(account, buildCernStatusLabel(ERROR));
- accountService.addLabel(account, buildCernMessageLabel(format(HR_DB_API_ERROR)));
+ if (voPerson.isEmpty()) {
+ setCernStatusLabel(a, CernStatus.EXPIRED, format(NO_PERSON_FOUND_MESSAGE, cernPersonId));
+ if (a.isValid()) {
+ expireAccount(a);
+ }
return;
}
- syncAccountInformation(account, voPerson.get());
+ syncAccountInformation(a, voPerson.get());
- Optional ep = getExperimentParticipation(voPerson.get(), experimentName);
+ Optional ep = CernHrLifecycleUtils
+ .getMostRecentMembership(voPerson.get().getParticipations(), experiment);
if (ep.isEmpty()) {
- LOG.warn("No participation to '{}' found for user {}", experimentName, account.getUsername());
- if (!account.hasLabelWithValue(buildCernStatusLabel(NOT_FOUND))) {
- accountService.setAccountEndTime(account, Date.from(checkTime));
- accountService.deleteLabel(account, buildLifecycleStatusLabel());
- accountService.addLabel(account, buildCernStatusLabel(NOT_FOUND));
- accountService.addLabel(account,
- buildCernMessageLabel(format(NO_PARTICIPATION_MESSAGE, experimentName)));
- LOG.debug("Updated end-time for '{}' as '{}' ...", account.getUsername(), account.getEndTime());
+ setCernStatusLabel(a, CernStatus.EXPIRED, format(NO_PARTICIPATION_MESSAGE, experiment));
+ if (a.isValid()) {
+ expireAccount(a);
}
return;
}
- accountService.setAccountEndTime(account, ep.get().getEndDate());
-
- LOG.debug("Updating end-time for {} to {} ...", account.getUsername(), ep.get().getEndDate());
- if (isValidExperimentParticipation(ep.get())) {
- accountService.addLabel(account, buildCernStatusLabel(MEMBER));
- if (account.isActive()) {
- accountService.addLabel(account, buildCernMessageLabel(format(VALID_MESSAGE)));
- return;
- }
- if (accountWasSuspendedByIamLifecycleJob(account)) {
- accountService.restoreAccount(account);
- accountService.addLabel(account,
- buildCernMessageLabel(format(RESTORED_MESSAGE, checkTime)));
- accountService.deleteLabel(account, buildLifecycleStatusLabel());
- }
- } else {
- accountService.addLabel(account, buildCernStatusLabel(EXPIRED));
- accountService.addLabel(account, buildCernMessageLabel(format(EXPIRED_MESSAGE)));
+
+ if (CernHrLifecycleUtils.isActiveMembership(ep.get().getEndDate()) && !a.isActive()
+ && accountWasSuspendedByIamLifecycleJob(a)) {
+ restoreAccount(a);
}
+ syncAccountEndTime(a, ep.get().getEndDate());
+ setCernStatusLabel(a, CernStatus.VO_MEMBER, format(SYNCHRONIZED_MESSAGE));
}
@Override
@@ -228,11 +150,11 @@ public void run() {
LOG.debug("accountsPage: {}", accountsPage);
if (accountsPage.hasContent()) {
- for (IamAccount account : accountsPage.getContent()) {
+ for (IamAccount a : accountsPage.getContent()) {
try {
- handleAccount(account);
+ handleAccount(getCernPersonId(a), cernProperties.getExperimentName(), a);
} catch (RuntimeException e) {
- LOG.error("Error during CERN HR lifecycle handler: {}", e.getMessage());
+ LOG.error("Error during CERN HR lifecycle handler on account {}: {}", a, e.getMessage());
}
}
}
@@ -259,47 +181,62 @@ public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
}
}
- private IamLabel buildCernActionLabel() {
- return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_ACTION).build();
+ private void deleteDeprecatedLabels(IamAccount a) {
+ /* remove deprecated labels: to be removed with a migration into next IAM release */
+ accountService.deleteLabel(a, CernHrLifecycleUtils.buildCernTimestampLabel());
+ accountService.deleteLabel(a, CernHrLifecycleUtils.buildCernActionLabel());
+ }
+
+ private void syncAccountInformation(IamAccount a, VOPersonDTO p) {
+ accountService.setAccountGivenName(a, p.getFirstName());
+ accountService.setAccountFamilyName(a, p.getName());
+ if (!isSkipEmailSynch(a)) {
+ try {
+ accountService.setAccountEmail(a, p.getEmail());
+ } catch (EmailAlreadyBoundException | NullPointerException e) {
+ LOG.error("Error on setting email for account {}: {}", a.getUuid(), e.getMessage());
+ }
+ }
}
- private IamLabel buildCernStatusLabel(Status status) {
- return IamLabel.builder()
- .prefix(LABEL_CERN_PREFIX)
- .name(LABEL_STATUS)
- .value(status.name())
- .build();
+ private boolean accountWasSuspendedByIamLifecycleJob(IamAccount a) {
+ Optional statusLabel = a.getLabelByName(LIFECYCLE_STATUS_LABEL);
+ return statusLabel.isPresent() && SUSPENDED_STATUSES.contains(statusLabel.get().getValue());
}
- private IamLabel buildCernTimestampLabel() {
- return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_TIMESTAMP).build();
+ private String getCernPersonId(IamAccount a) {
+ Optional cernPersonIdLabel =
+ a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, cernProperties.getPersonIdClaim());
+ Assert.isTrue(cernPersonIdLabel.isPresent(), INVALID_ACCOUNT_MESSAGE);
+ return cernPersonIdLabel.get().getValue();
}
- private IamLabel buildCernIgnoreLabel() {
- return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_IGNORE).build();
+ private boolean isSkipEmailSynch(IamAccount a) {
+ return a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_SKIP_EMAIL_SYNCH).isPresent();
}
- private IamLabel buildCernMessageLabel(String message) {
- return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_MESSAGE).value(message).build();
+ private boolean isSkipEndDateSynch(IamAccount a) {
+ return a.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_SKIP_END_DATE_SYNCH).isPresent();
}
- private IamLabel buildLifecycleStatusLabel() {
- return IamLabel.builder().name(LIFECYCLE_STATUS_LABEL).build();
+ private void setCernStatusLabel(IamAccount a, CernStatus status, String message) {
+ IamLabel statusLabel = CernHrLifecycleUtils.buildCernStatusLabel(status);
+ IamLabel messageLabel = CernHrLifecycleUtils.buildCernMessageLabel(message);
+ accountService.addLabel(a, statusLabel);
+ accountService.addLabel(a, messageLabel);
}
- private Optional getExperimentParticipation(VOPersonDTO voPerson,
- String experimentName) {
- return voPerson.getParticipations()
- .stream()
- .filter(p -> p.getExperiment().equalsIgnoreCase(experimentName))
- .findFirst();
+ private void restoreAccount(IamAccount a) {
+ accountService.restoreAccount(a);
}
- private boolean isValidExperimentParticipation(ParticipationDTO participation) {
- if (Objects.isNull(participation.getEndDate())) {
- return true;
+ private void syncAccountEndTime(IamAccount a, Date endDate) {
+ if (!isSkipEndDateSynch(a)) {
+ accountService.setAccountEndTime(a, endDate);
}
- return DateTimeComparator.getDateOnlyInstance()
- .compare(participation.getEndDate(), new Date()) >= 0;
+ }
+
+ private void expireAccount(IamAccount a) {
+ accountService.setAccountEndTime(a, new Date());
}
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java
new file mode 100644
index 000000000..aded51bea
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernHrLifecycleUtils.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.lifecycle.cern;
+
+import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL;
+import static java.util.Comparator.comparing;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Comparator.nullsLast;
+
+import java.util.Comparator;
+import java.util.Date;
+import java.util.Optional;
+import java.util.Set;
+
+import org.joda.time.DateTimeComparator;
+
+import it.infn.mw.iam.api.registration.cern.dto.ParticipationDTO;
+import it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler;
+import it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamLabel;
+
+public class CernHrLifecycleUtils {
+
+ public static final String LABEL_CERN_PREFIX = "hr.cern";
+ public static final String LABEL_STATUS = "status";
+ public static final String LABEL_TIMESTAMP = "timestamp";
+ public static final String LABEL_MESSAGE = "message";
+ public static final String LABEL_ACTION = "action";
+ public static final String LABEL_IGNORE = "ignore";
+ public static final String LABEL_SKIP_EMAIL_SYNCH = "skip-email-synch";
+ public static final String LABEL_SKIP_END_DATE_SYNCH = "skip-end-date-synch";
+
+ private CernHrLifecycleUtils() {}
+
+ public static IamLabel buildCernActionLabel() {
+
+ return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_ACTION).build();
+ }
+
+ public static IamLabel buildCernStatusLabel(CernStatus status) {
+
+ return IamLabel.builder()
+ .prefix(LABEL_CERN_PREFIX)
+ .name(LABEL_STATUS)
+ .value(status.name())
+ .build();
+ }
+
+ public static IamLabel buildCernTimestampLabel() {
+
+ return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_TIMESTAMP).build();
+ }
+
+ public static IamLabel buildCernIgnoreLabel() {
+
+ return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_IGNORE).build();
+ }
+
+ public static IamLabel buildCernMessageLabel(String message) {
+
+ return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_MESSAGE).value(message).build();
+ }
+
+ public static IamLabel buildLifecycleStatusLabel() {
+ return IamLabel.builder().name(LIFECYCLE_STATUS_LABEL).build();
+ }
+
+ public static IamLabel buildLifecycleStatusLabel(AccountLifecycleStatus status) {
+ return IamLabel.builder()
+ .name(ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL)
+ .value(status.name())
+ .build();
+ }
+
+ public static Optional getMostRecentMembership(Set p,
+ String experimentName) {
+ Comparator comparator =
+ nullsLast(comparing(ParticipationDTO::getEndDate, nullsLast(naturalOrder())).reversed());
+ return p.stream()
+ .filter(c -> c.getExperiment().equalsIgnoreCase(experimentName))
+ .sorted(comparator)
+ .findFirst();
+ }
+
+ public static boolean isActiveMembership(Date endTime) {
+ if (endTime == null) {
+ return true;
+ }
+ return DateTimeComparator.getDateOnlyInstance().compare(endTime, new Date()) >= 0;
+ }
+
+ public static boolean isAccountIgnored(IamAccount a) {
+ return a.hasLabel(buildCernIgnoreLabel());
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java
new file mode 100644
index 000000000..54695d042
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/lifecycle/cern/CernStatus.java
@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.lifecycle.cern;
+
+public enum CernStatus {
+ IGNORED, ERROR, EXPIRED, VO_MEMBER
+}
\ No newline at end of file
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java
index 970924df1..4fc5003b2 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/IamOAuthConfirmationController.java
@@ -25,6 +25,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import org.apache.http.client.utils.URIBuilder;
import org.mitre.oauth2.model.ClientDetailsEntity;
@@ -46,9 +47,8 @@
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.endpoint.RedirectResolver;
import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
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.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
@@ -63,16 +63,11 @@
import it.infn.mw.iam.core.oauth.scope.pdp.ScopePolicyPDP;
import it.infn.mw.iam.persistence.model.IamAccount;
-/**
- * @author jricher
- *
- */
@SuppressWarnings("deprecation")
@Controller
@SessionAttributes("authorizationRequest")
public class IamOAuthConfirmationController {
-
@Autowired
private ClientDetailsEntityService clientService;
@@ -104,6 +99,8 @@ public class IamOAuthConfirmationController {
private static final Logger logger =
LoggerFactory.getLogger(IamOAuthConfirmationController.class);
+ private static final Set adminScopes = Set.of("iam:admin.read", "iam:admin.write");
+
public IamOAuthConfirmationController() {
}
@@ -113,7 +110,7 @@ public IamOAuthConfirmationController(ClientDetailsEntityService clientService)
}
@PreAuthorize("hasRole('ROLE_USER')")
- @RequestMapping(path = "/oauth/confirm_access", method = RequestMethod.GET)
+ @GetMapping(path = "/oauth/confirm_access")
public String confimAccess(Map model,
@ModelAttribute("authorizationRequest") AuthorizationRequest authRequest,
Authentication authUser, SessionStatus status) {
@@ -145,26 +142,7 @@ public String confimAccess(Map model,
if (prompts.contains("none")) {
// if we've got a redirect URI then we'll send it
-
- String url = redirectResolver.resolveRedirect(authRequest.getRedirectUri(), client);
-
- try {
- URIBuilder uriBuilder = new URIBuilder(url);
-
- uriBuilder.addParameter("error", "interaction_required");
- if (!Strings.isNullOrEmpty(authRequest.getState())) {
- uriBuilder.addParameter("state", authRequest.getState()); // copy the state parameter if
- // one was given
- }
-
- status.setComplete();
- return "redirect:" + uriBuilder.toString();
-
- } catch (URISyntaxException e) {
- logger.error("Can't build redirect URI for prompt=none, sending error instead", e);
- model.put("code", HttpStatus.FORBIDDEN);
- return HttpCodeView.VIEWNAME;
- }
+ return handleRedirectOrFail(model, authRequest, status, client);
}
model.put("auth_request", authRequest);
@@ -178,16 +156,43 @@ public String confimAccess(Map model,
// pre-process the scopes
Set scopes = scopeService.fromStrings(authRequest.getScope());
- Set sortedScopes = new LinkedHashSet<>(scopes.size());
Set systemScopes = scopeService.getAll();
- // filter requested scopes according to the scope policy
- IamAccount account = accountUtils.getAuthenticatedUserAccount(authUser)
- .orElseThrow(() -> NoSuchAccountError.forUsername(authUser.getName()));
+ Set filteredScopes = getFilteredScopes(scopes, authUser);
- Set filteredScopes = pdp.filterScopes(scopeService.toStrings(scopes), account);
+ Set sortedScopes = getSortedScopes(systemScopes, filteredScopes);
+
+ model.put("scopes", sortedScopes);
+
+ authRequest.setScope(scopeService.toStrings(sortedScopes));
+
+ model.put("claims", getClaimsForScopes(sortedScopes, authUser.getName()));
+
+ // client stats
+ Integer count = statsService.getCountForClientId(client.getClientId()).getApprovedSiteCount();
+ model.put("count", count);
+
+ // contacts
+ if (!client.getContacts().isEmpty()) {
+ String contacts = Joiner.on(", ").join(client.getContacts());
+ model.put("contacts", contacts);
+ }
+
+ // if the client is over a week old and has more than one registration, don't give such a big
+ // warning instead, tag as "Generally Recognized As Safe" (gras)
+ Date lastWeek = new Date(System.currentTimeMillis() - (60 * 60 * 24 * 7 * 1000));
+ Boolean expression =
+ count > 1 && client.getCreatedAt() != null && client.getCreatedAt().before(lastWeek);
+ model.put("gras", expression);
+
+ return "iam/approveClient";
+ }
+
+ private Set getSortedScopes(Set systemScopes,
+ Set filteredScopes) {
+
+ Set sortedScopes = new LinkedHashSet<>(systemScopes.size());
- // sort scopes for display based on the inherent order of system scopes
for (SystemScope s : systemScopes) {
if (scopeService.fromStrings(filteredScopes).contains(s)) {
sortedScopes.add(s);
@@ -195,54 +200,72 @@ public String confimAccess(Map model,
}
// add in any scopes that aren't system scopes to the end of the list
- sortedScopes.addAll(Sets.difference(scopes, systemScopes));
+ sortedScopes.addAll(Sets.difference(scopeService.fromStrings(filteredScopes), systemScopes));
- model.put("scopes", sortedScopes);
+ return sortedScopes;
+ }
- authRequest.setScope(scopeService.toStrings(sortedScopes));
+ private Set getFilteredScopes(Set scopes, Authentication authUser)
+ throws NoSuchAccountError {
+
+ IamAccount account = accountUtils.getAuthenticatedUserAccount(authUser)
+ .orElseThrow(() -> NoSuchAccountError.forUsername(authUser.getName()));
+
+ Set filteredScopes = pdp.filterScopes(scopeService.toStrings(scopes), account);
+
+ if (!accountUtils.isAdmin(authUser)) {
+ filteredScopes =
+ filteredScopes.stream().filter(s -> !adminScopes.contains(s)).collect(Collectors.toSet());
+ }
+ return filteredScopes;
+ }
+
+ private Map> getClaimsForScopes(Set sortedScopes,
+ String username) {
+
+ UserInfo user = userInfoService.getByUsername(username);
- // get the userinfo claims for each scope
- UserInfo user = userInfoService.getByUsername(authUser.getName());
Map> claimsForScopes = new HashMap<>();
- if (user != null) {
- JsonObject userJson = user.toJson();
-
- for (SystemScope systemScope : sortedScopes) {
- Map claimValues = new HashMap<>();
-
- Set claims = scopeClaimTranslationService.getClaimsForScope(systemScope.getValue());
- for (String claim : claims) {
- if (userJson.has(claim) && userJson.get(claim).isJsonPrimitive()) {
- // TODO: this skips the address claim
- claimValues.put(claim, userJson.get(claim).getAsString());
- }
- }
+ if (user == null) {
+ return claimsForScopes;
+ }
+
+ JsonObject userJson = user.toJson();
+ for (SystemScope systemScope : sortedScopes) {
+ Map claimValues = new HashMap<>();
- claimsForScopes.put(systemScope.getValue(), claimValues);
+ Set claims = scopeClaimTranslationService.getClaimsForScope(systemScope.getValue());
+ for (String claim : claims) {
+ if (userJson.has(claim) && userJson.get(claim).isJsonPrimitive()) {
+ claimValues.put(claim, userJson.get(claim).getAsString());
+ }
}
+ claimsForScopes.put(systemScope.getValue(), claimValues);
}
+ return claimsForScopes;
+ }
- model.put("claims", claimsForScopes);
+ private String handleRedirectOrFail(Map model, AuthorizationRequest authRequest,
+ SessionStatus status, ClientDetailsEntity client) {
- // client stats
- Integer count = statsService.getCountForClientId(client.getClientId()).getApprovedSiteCount();
- model.put("count", count);
+ String url = redirectResolver.resolveRedirect(authRequest.getRedirectUri(), client);
+ try {
+ URIBuilder uriBuilder = new URIBuilder(url);
- // contacts
- if (client.getContacts() != null) {
- String contacts = Joiner.on(", ").join(client.getContacts());
- model.put("contacts", contacts);
- }
+ uriBuilder.addParameter("error", "interaction_required");
+ if (!Strings.isNullOrEmpty(authRequest.getState())) {
+ uriBuilder.addParameter("state", authRequest.getState());
+ }
- // if the client is over a week old and has more than one registration, don't give such a big
- // warning
- // instead, tag as "Generally Recognized As Safe" (gras)
- Date lastWeek = new Date(System.currentTimeMillis() - (60 * 60 * 24 * 7 * 1000));
- Boolean expression = count > 1 && client.getCreatedAt() != null && client.getCreatedAt().before(lastWeek);
- model.put("gras", expression);
+ status.setComplete();
+ return "redirect:" + uriBuilder.toString();
- return "iam/approveClient";
+ } catch (URISyntaxException e) {
+ logger.error("Can't build redirect URI for prompt=none, sending error instead", e);
+ model.put("code", HttpStatus.FORBIDDEN);
+ return HttpCodeView.VIEWNAME;
+ }
}
/**
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java
index 3544079e0..734fb2e85 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/common/BaseIdTokenCustomizer.java
@@ -29,6 +29,7 @@
import it.infn.mw.iam.persistence.model.IamLabel;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+@SuppressWarnings("deprecation")
public abstract class BaseIdTokenCustomizer implements IDTokenCustomizer {
private final IamAccountRepository accountRepo;
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java
index f1a064ea7..4509a44d4 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGProfileAccessTokenBuilder.java
@@ -75,6 +75,11 @@ public JWTClaimsSet buildAccessToken(OAuth2AccessTokenEntity token,
if (properties.getAccessToken().isIncludeAuthnInfo()) {
addAuthnInfoClaims(builder, token.getScope(), userInfo);
}
+
+ if (token.getScope().contains(ATTR_SCOPE)) {
+ builder.claim(ATTR_SCOPE, attributeHelper
+ .getAttributeMapFromUserInfo(((UserInfoAdapter) userInfo).getUserinfo()));
+ }
}
addAudience(builder, authentication);
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/pdp/IamPDPScopeFilter.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/pdp/IamPDPScopeFilter.java
index 68563bc6b..2abc50c09 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/pdp/IamPDPScopeFilter.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/scope/pdp/IamPDPScopeFilter.java
@@ -17,13 +17,14 @@
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Collectors;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.stereotype.Component;
+import it.infn.mw.iam.api.account.AccountUtils;
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
@@ -32,27 +33,31 @@
@ConditionalOnProperty(name = "iam.enableScopeAuthz", havingValue = "true")
public class IamPDPScopeFilter implements IamScopeFilter {
+ private static final Set adminScopes = Set.of("iam:admin.read", "iam:admin.write");
+
final ScopePolicyPDP pdp;
final IamAccountRepository accountRepo;
+ final AccountUtils accountUtils;
- @Autowired
- public IamPDPScopeFilter(ScopePolicyPDP pdp, IamAccountRepository accountRepo) {
+ public IamPDPScopeFilter(ScopePolicyPDP pdp, IamAccountRepository accountRepo,
+ AccountUtils accountUtils) {
this.pdp = pdp;
this.accountRepo = accountRepo;
+ this.accountUtils = accountUtils;
}
protected Optional resolveIamAccount(Authentication authn) {
-
- if (authn == null){
+
+ if (authn == null) {
return Optional.empty();
}
-
+
Authentication userAuthn = authn;
-
- if (authn instanceof OAuth2Authentication){
+
+ if (authn instanceof OAuth2Authentication) {
userAuthn = ((OAuth2Authentication) authn).getUserAuthentication();
}
-
+
if (userAuthn == null) {
return Optional.empty();
}
@@ -63,13 +68,18 @@ protected Optional resolveIamAccount(Authentication authn) {
@Override
public void filterScopes(Set scopes, Authentication authn) {
-
+
Optional maybeAccount = resolveIamAccount(authn);
if (maybeAccount.isPresent()) {
- Set filteredScopes =
- pdp.filterScopes(scopes, maybeAccount.get());
-
+ Set filteredScopes = pdp.filterScopes(scopes, maybeAccount.get());
+
+ if (!accountUtils.isAdmin(authn)) {
+ filteredScopes = filteredScopes.stream()
+ .filter(s -> !adminScopes.contains(s))
+ .collect(Collectors.toSet());
+ }
+
scopes.retainAll(filteredScopes);
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java
index 1c8d045f1..e1b161792 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/DefaultIamAccountService.java
@@ -18,6 +18,7 @@
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.isNullOrEmpty;
+import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL;
import static java.lang.String.format;
import static java.util.Objects.isNull;
@@ -400,6 +401,7 @@ public IamAccount addLabel(IamAccount account, IamLabel label) {
@Override
public IamAccount deleteLabel(IamAccount account, IamLabel label) {
+
boolean labelRemoved = account.getLabels().remove(label);
if (labelRemoved) {
@@ -439,7 +441,7 @@ public IamAccount setAccountFamilyName(IamAccount account, String familyName) {
public IamAccount setAccountEmail(IamAccount account, String email)
throws EmailAlreadyBoundException {
checkNotNull(account, "Cannot set email on a null account");
- checkNotNull(email, "Cannot set null email");
+ checkNotNull(email, "Cannot set null email on account");
if (ObjectUtils.notEqual(account.getUserInfo().getEmail(), email)) {
Optional o = accountRepo.findByEmailWithDifferentUUID(email, account.getUuid());
if (o.isPresent()) {
@@ -460,6 +462,7 @@ public IamAccount setAccountEndTime(IamAccount account, Date endTime) {
Date previousEndTime = account.getEndTime();
if (ObjectUtils.notEqual(previousEndTime, endTime)) {
account.setEndTime(endTime);
+ account.removeLabelByName(LIFECYCLE_STATUS_LABEL);
account.touch();
accountRepo.save(account);
eventPublisher.publishEvent(new AccountEndTimeUpdatedEvent(this, account, previousEndTime,
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java
index 4384936ae..cbd1cd2e2 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/IamAccountService.java
@@ -34,14 +34,15 @@
*/
public interface IamAccountService {
-
+
/**
* Finds an account by UUID
- * @param uuid
+ *
+ * @param uuid
* @return an {@link Optional} iam account
*/
Optional findByUuid(String uuid);
-
+
/**
* Creates a new {@link IamAccount}, after some checks.
*
@@ -122,6 +123,7 @@ public interface IamAccountService {
/**
* Sets end time for a given account
+ *
* @param account
* @param endTime
* @return the updated account
@@ -130,18 +132,20 @@ public interface IamAccountService {
/**
* Disables account
+ *
* @param account
* @return the updated account
*/
IamAccount disableAccount(IamAccount account);
-
+
/**
* Restores account
+ *
* @param account
* @return the updated account
*/
IamAccount restoreAccount(IamAccount account);
-
+
/**
* Sets an attribute for the account
*
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretAlreadyBoundException.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretAlreadyBoundException.java
new file mode 100644
index 000000000..6da0bd856
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretAlreadyBoundException.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.user.exception;
+
+public class MfaSecretAlreadyBoundException extends IamAccountException {
+
+ private static final long serialVersionUID = 1L;
+
+ public MfaSecretAlreadyBoundException(String message) {
+ super(message);
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretNotFoundException.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretNotFoundException.java
new file mode 100644
index 000000000..10c80adcb
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/MfaSecretNotFoundException.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.user.exception;
+
+import org.springframework.security.core.AuthenticationException;
+
+public class MfaSecretNotFoundException extends AuthenticationException {
+
+ private static final long serialVersionUID = 1L;
+
+ public MfaSecretNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/TotpMfaAlreadyEnabledException.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/TotpMfaAlreadyEnabledException.java
new file mode 100644
index 000000000..32b83f501
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/user/exception/TotpMfaAlreadyEnabledException.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.user.exception;
+
+public class TotpMfaAlreadyEnabledException extends IamAccountException {
+
+ private static final long serialVersionUID = 1L;
+
+ public TotpMfaAlreadyEnabledException(String message) {
+ super(message);
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java
index 6b9399298..6cf194a3a 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/jwk/IamJWKSetPublishingEndpoint.java
@@ -28,6 +28,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
+
import org.springframework.web.bind.annotation.RestController;
import com.nimbusds.jose.jwk.JWK;
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java
index 044150624..2ae467ab5 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/DefaultLoginPageConfiguration.java
@@ -20,7 +20,6 @@
import javax.annotation.PostConstruct;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
@@ -30,8 +29,9 @@
import com.google.common.base.Strings;
import it.infn.mw.iam.config.IamProperties;
-import it.infn.mw.iam.config.IamProperties.Logo;
import it.infn.mw.iam.config.IamProperties.LoginPageLayout.ExternalAuthnOptions;
+import it.infn.mw.iam.config.IamProperties.Logo;
+import it.infn.mw.iam.config.mfa.IamTotpMfaProperties;
import it.infn.mw.iam.config.oidc.OidcProvider;
import it.infn.mw.iam.config.oidc.OidcValidatedProviders;
@@ -50,6 +50,7 @@ public class DefaultLoginPageConfiguration implements LoginPageConfiguration, En
private boolean localAuthenticationVisible;
private boolean showLinkToLocalAuthn;
private boolean defaultLoginPageLayout;
+ private boolean mfaSettingsBtnEnabled;
@Value("${iam.account-linking.enable}")
private Boolean accountLinkingEnabled;
@@ -57,11 +58,15 @@ public class DefaultLoginPageConfiguration implements LoginPageConfiguration, En
private OidcValidatedProviders providers;
private final IamProperties iamProperties;
+ private final IamTotpMfaProperties iamTotpMfaProperties;
- @Autowired
- public DefaultLoginPageConfiguration(OidcValidatedProviders providers, IamProperties properties) {
+ public DefaultLoginPageConfiguration(
+ OidcValidatedProviders providers,
+ IamProperties properties,
+ IamTotpMfaProperties iamTotpMfaProperties) {
this.providers = providers;
this.iamProperties = properties;
+ this.iamTotpMfaProperties = iamTotpMfaProperties;
}
@@ -78,6 +83,7 @@ public void init() {
.equals(iamProperties.getLocalAuthn().getLoginPageVisibility());
defaultLoginPageLayout = IamProperties.LoginPageLayoutOptions.LOGIN_FORM
.equals(iamProperties.getLoginPageLayout().getSectionToBeDisplayedFirst());
+ mfaSettingsBtnEnabled = iamTotpMfaProperties.hasMultiFactorSettingsBtnEnabled();
}
@Override
@@ -169,6 +175,10 @@ public boolean isShowLinkToLocalAuthenticationPage() {
return showLinkToLocalAuthn;
}
+ @Override
+ public boolean isMfaSettingsBtnEnabled() {
+ return mfaSettingsBtnEnabled;
+ }
@Override
public boolean isShowRegistrationButton() {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java
index 6e516855e..95f146e2e 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/loginpage/LoginPageConfiguration.java
@@ -30,6 +30,8 @@ public interface LoginPageConfiguration {
boolean isShowLinkToLocalAuthenticationPage();
+ boolean isMfaSettingsBtnEnabled();
+
boolean isExternalAuthenticationEnabled();
boolean isOidcEnabled();
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/DefaultMultiFactorVerificationPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/DefaultMultiFactorVerificationPageConfiguration.java
new file mode 100644
index 000000000..cf0d39708
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/DefaultMultiFactorVerificationPageConfiguration.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.web.multi_factor_authentication;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import it.infn.mw.iam.config.IamProperties;
+import it.infn.mw.iam.config.IamProperties.Logo;
+
+import com.google.common.base.Strings;
+
+/**
+ * Config for the Verify button that appears at the /iam/verify MFA endpoint
+ */
+@Component
+public class DefaultMultiFactorVerificationPageConfiguration
+ implements MultiFactorVerificationPageConfiguration {
+
+ private final IamProperties iamProperties;
+
+ public static final String DEFAULT_VERIFICATION_BUTTON_TEXT = "Verify";
+
+ @Autowired
+ public DefaultMultiFactorVerificationPageConfiguration(IamProperties properties) {
+ this.iamProperties = properties;
+ }
+
+ @Override
+ public Logo getLogo() {
+ return iamProperties.getLogo();
+ }
+
+ @Override
+ public String getVerifyButtonText() {
+ if (Strings.isNullOrEmpty(iamProperties.getVerifyButton().getText())) {
+ return DEFAULT_VERIFICATION_BUTTON_TEXT;
+ }
+ return iamProperties.getVerifyButton().getText();
+ }
+
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/MultiFactorVerificationPageConfiguration.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/MultiFactorVerificationPageConfiguration.java
new file mode 100644
index 000000000..8cf3f49a5
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/multi_factor_authentication/MultiFactorVerificationPageConfiguration.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.core.web.multi_factor_authentication;
+
+import it.infn.mw.iam.config.IamProperties.Logo;
+
+/**
+ * Config for the Verify button that appears at the /iam/verify MFA endpoint
+ */
+public interface MultiFactorVerificationPageConfiguration {
+
+ String getVerifyButtonText();
+
+ Logo getLogo();
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java
index 06d4e458c..7c11c4328 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/util/IamViewInfoInterceptor.java
@@ -27,12 +27,15 @@
import it.infn.mw.iam.config.client_registration.ClientRegistrationProperties;
import it.infn.mw.iam.config.saml.IamSamlProperties;
import it.infn.mw.iam.core.web.loginpage.LoginPageConfiguration;
+import it.infn.mw.iam.core.web.multi_factor_authentication.MultiFactorVerificationPageConfiguration;
import it.infn.mw.iam.rcauth.RCAuthProperties;
@Component
public class IamViewInfoInterceptor implements HandlerInterceptor {
public static final String LOGIN_PAGE_CONFIGURATION_KEY = "loginPageConfiguration";
+ public static final String MULTI_FACTOR_VERIFICATION_KEY =
+ "multiFactorVerificationPageConfiguration";
public static final String ORGANISATION_NAME_KEY = "iamOrganisationName";
public static final String IAM_SAML_PROPERTIES_KEY = "iamSamlProperties";
public static final String IAM_OIDC_PROPERTIES_KEY = "iamOidcProperties";
@@ -52,13 +55,16 @@ public class IamViewInfoInterceptor implements HandlerInterceptor {
@Value("${iam.organisation.name}")
String organisationName;
-
+
@Autowired
LoginPageConfiguration loginPageConfiguration;
+ @Autowired
+ MultiFactorVerificationPageConfiguration multiFactorVerificationPageConfiguration;
+
@Autowired
IamSamlProperties samlProperties;
-
+
@Autowired
RCAuthProperties rcAuthProperties;
@@ -71,16 +77,18 @@ public class IamViewInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
-
+
request.setAttribute(IAM_VERSION_KEY, iamVersion);
request.setAttribute(GIT_COMMIT_ID_KEY, gitCommitId);
request.setAttribute(ORGANISATION_NAME_KEY, organisationName);
-
+
request.setAttribute(LOGIN_PAGE_CONFIGURATION_KEY, loginPageConfiguration);
-
+
+ request.setAttribute(MULTI_FACTOR_VERIFICATION_KEY, multiFactorVerificationPageConfiguration);
+
request.setAttribute(IAM_SAML_PROPERTIES_KEY, samlProperties);
-
+
request.setAttribute(RCAUTH_ENABLED_KEY, rcAuthProperties.isEnabled());
request.setAttribute(CLIENT_DEFAULTS_PROPERTIES_KEY, clientRegistrationProperties.getClientDefaults());
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java
index 587645ba3..6695cce1a 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamDiscoveryEndpoint.java
@@ -23,11 +23,11 @@
import org.mitre.openid.connect.view.JsonEntityView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@@ -49,7 +49,6 @@ public class IamDiscoveryEndpoint {
private final UserInfoService userService;
private final WellKnownInfoProvider wellKnownInfoProvider;
- @Autowired
public IamDiscoveryEndpoint(ConfigurationPropertiesBean config, UserInfoService userService,
WellKnownInfoProvider wellKnownInfoProvider) {
this.config = config;
@@ -57,7 +56,7 @@ public IamDiscoveryEndpoint(ConfigurationPropertiesBean config, UserInfoService
this.wellKnownInfoProvider = wellKnownInfoProvider;
}
- @RequestMapping(value = {"/" + WEBFINGER_URL}, method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
+ @GetMapping(value = {"/" + WEBFINGER_URL}, produces = MediaType.APPLICATION_JSON_VALUE)
public String webfinger(@RequestParam("resource") String resource,
@RequestParam(value = "rel", required = false) String rel, Model model) {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java b/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java
index a16359b05..d41fc667c 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/notification/NotificationFactory.java
@@ -57,4 +57,8 @@ IamEmailNotification createClientStatusChangedMessageFor(ClientDetailsEntity cli
IamEmailNotification createAccountSuspendedMessage(IamAccount account);
IamEmailNotification createAccountRestoredMessage(IamAccount account);
+
+ IamEmailNotification createMfaDisableMessage(IamAccount account);
+
+ IamEmailNotification createMfaEnableMessage(IamAccount account);
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java b/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java
index 77ab7c021..01d0280c6 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/notification/TransientNotificationFactory.java
@@ -406,6 +406,44 @@ public IamEmailNotification createAccountRestoredMessage(IamAccount account) {
}
+ @Override
+ public IamEmailNotification createMfaEnableMessage(IamAccount account) {
+ String recipient = account.getUserInfo().getName();
+
+ Map model = new HashMap<>();
+ model.put(RECIPIENT_FIELD, recipient);
+ model.put(ORGANISATION_NAME, organisationName);
+
+ String subject = "Multi-factor authentication (MFA) enabled";
+
+ IamEmailNotification notification =
+ createMessage("mfaEnable.ftl", model, IamNotificationType.MFA_ENABLE,
+ subject, asList(account.getUserInfo().getEmail()));
+
+ LOG.debug("Created Multi-factor authentication (MFA) enabled message for the account {}", account.getUuid());
+
+ return notification;
+ }
+
+ @Override
+ public IamEmailNotification createMfaDisableMessage(IamAccount account) {
+ String recipient = account.getUserInfo().getName();
+
+ Map model = new HashMap<>();
+ model.put(RECIPIENT_FIELD, recipient);
+ model.put(ORGANISATION_NAME, organisationName);
+
+ String subject = "Multi-factor authentication (MFA) disabled";
+
+ IamEmailNotification notification =
+ createMessage("mfaDisable.ftl", model, IamNotificationType.MFA_DISABLE,
+ subject, asList(account.getUserInfo().getEmail()));
+
+ LOG.debug("Created Multi-factor authentication (MFA) disabled message for the account {}", account.getUuid());
+
+ return notification;
+ }
+
protected IamEmailNotification createMessage(String templateName, Map model,
IamNotificationType messageType, String subject, List receiverAddress) {
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/registration/DefaultRegistrationRequestService.java b/iam-login-service/src/main/java/it/infn/mw/iam/registration/DefaultRegistrationRequestService.java
index 5c476b346..e85807530 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/registration/DefaultRegistrationRequestService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/registration/DefaultRegistrationRequestService.java
@@ -358,15 +358,19 @@ private RegistrationRequestDto handleConfirm(IamRegistrationRequest request) {
}
private RegistrationRequestDto handleReject(IamRegistrationRequest request,
- Optional motivation) {
+ Optional motivation, boolean doNotSendEmail) {
request.setStatus(REJECTED);
- notificationFactory.createRequestRejectedMessage(request, motivation);
+ if(!doNotSendEmail){
+ notificationFactory.createRequestRejectedMessage(request, motivation);
+ }
+
RegistrationRequestDto retval = converter.fromEntity(request);
accountService.deleteAccount(request.getAccount());
eventPublisher.publishEvent(new RegistrationRejectEvent(this, request,
- "Reject registration request for user " + request.getAccount().getUsername()));
+ "Reject registration request for user " + request.getAccount().getUsername() +
+ (motivation.isPresent() ? " with motivation: " + motivation.get() : "")));
return retval;
}
@@ -387,7 +391,7 @@ public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
}
@Override
- public RegistrationRequestDto rejectRequest(String requestUuid, Optional motivation) {
+ public RegistrationRequestDto rejectRequest(String requestUuid, Optional motivation, boolean doNotSendEmail) {
IamRegistrationRequest request = findRequestById(requestUuid);
@@ -396,7 +400,7 @@ public RegistrationRequestDto rejectRequest(String requestUuid, Optional
String.format("Bad status transition from [%s] to [%s]", request.getStatus(), APPROVED));
}
- return handleReject(request, motivation);
+ return handleReject(request, motivation, doNotSendEmail);
}
@Override
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationApiController.java b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationApiController.java
index c6372506b..ce6333054 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationApiController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationApiController.java
@@ -15,6 +15,9 @@
*/
package it.infn.mw.iam.registration;
+import static it.infn.mw.iam.api.utils.ValidationErrorUtils.stringifyValidationError;
+import static java.lang.String.format;
+
import java.util.List;
import java.util.Optional;
@@ -23,7 +26,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -35,12 +37,11 @@
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
@@ -56,8 +57,6 @@
import it.infn.mw.iam.config.IamProperties.RegistrationProperties;
import it.infn.mw.iam.core.IamRegistrationRequestStatus;
import it.infn.mw.iam.registration.validation.RegistrationRequestValidatorError;
-import static it.infn.mw.iam.api.utils.ValidationErrorUtils.stringifyValidationError;
-import static java.lang.String.format;
@RestController
@Transactional
@@ -72,7 +71,6 @@ public class RegistrationApiController {
private static final String INVALID_REGISTRATION_TEMPLATE = "Invalid registration request: %s";
- @Autowired
public RegistrationApiController(RegistrationRequestService registrationService,
IamProperties properties) {
service = registrationService;
@@ -97,8 +95,7 @@ private Optional getExternalAuthenticati
}
@PreAuthorize("#iam.hasScope('registration:read') or hasRole('ADMIN')")
- @RequestMapping(value = "/registration/list", method = RequestMethod.GET)
- @ResponseBody
+ @GetMapping(value = "/registration/list")
public List listRequests(
@RequestParam(value = "status", required = false) IamRegistrationRequestStatus status) {
@@ -106,15 +103,13 @@ public List listRequests(
}
@PreAuthorize("#iam.hasScope('registration:read') or hasRole('ADMIN')")
- @RequestMapping(value = "/registration/list/pending", method = RequestMethod.GET)
- @ResponseBody
+ @GetMapping(value = "/registration/list/pending")
public List listPendingRequests() {
return service.listPendingRequests();
}
- @RequestMapping(value = "/registration/create", method = RequestMethod.POST,
- consumes = "application/json")
+ @PostMapping(value = "/registration/create", consumes = "application/json")
public RegistrationRequestDto createRegistrationRequest(
@Valid @RequestBody @JsonView(
value = RegistrationViews.RegistrationDetail.class) RegistrationRequestDto request,
@@ -124,33 +119,34 @@ public RegistrationRequestDto createRegistrationRequest(
}
@PreAuthorize("#iam.hasScope('registration:write') or hasRole('ADMIN')")
- @RequestMapping(value = "/registration/approve/{uuid}", method = RequestMethod.POST)
+ @PostMapping(value = "/registration/approve/{uuid}")
public RegistrationRequestDto approveRequest(@PathVariable("uuid") String uuid) {
return service.approveRequest(uuid);
}
@PreAuthorize("#iam.hasScope('registration:write') or hasRole('ADMIN')")
- @RequestMapping(value = "/registration/reject/{uuid}", method = RequestMethod.POST)
+ @PostMapping(value = "/registration/reject/{uuid}")
public RegistrationRequestDto rejectRequest(@PathVariable("uuid") String uuid,
- @RequestParam(required = false) String motivation) {
+ @RequestParam(required = false) String motivation, @RequestParam(required = false) boolean doNotSendEmail) {
- return service.rejectRequest(uuid, Optional.ofNullable(motivation));
+ return service.rejectRequest(uuid, Optional.ofNullable(motivation), doNotSendEmail);
}
- @RequestMapping(value = "/registration/confirm/{token}", method = RequestMethod.GET)
- public RegistrationRequestDto confirmRequest(@PathVariable("token") String token) {
+ @GetMapping(value = "/registration/verify/{token}")
+ public ModelAndView openConfirmRequestPage(final Model model, @PathVariable("token") String token) {
- return service.confirmRequest(token);
+ model.addAttribute("token", token);
+ return new ModelAndView("iam/confirmRequest");
}
- @RequestMapping(value = "/registration/verify/{token}", method = RequestMethod.GET)
- public ModelAndView verify(final Model model, @PathVariable("token") String token) {
+ @PostMapping(value = "/registration/verify")
+ public ModelAndView verifyRequest(final Model model, @RequestParam("token") String token) {
try {
service.confirmRequest(token);
model.addAttribute("verificationSuccess", true);
SecurityContextHolder.clearContext();
} catch (ScimResourceNotFoundException e) {
- LOG.warn(e.getMessage(), e);
+ LOG.warn(e.getMessage());
String message = "Activation failed: " + e.getMessage();
model.addAttribute("verificationMessage", message);
model.addAttribute("verificationFailure", true);
@@ -159,7 +155,7 @@ public ModelAndView verify(final Model model, @PathVariable("token") String toke
return new ModelAndView("iam/requestVerified");
}
- @RequestMapping(value = "/registration/insufficient-auth", method = RequestMethod.GET)
+ @GetMapping(value = "/registration/insufficient-auth")
public ModelAndView insufficientAuth(final Model model, final HttpServletRequest request,
final Authentication auth) {
@@ -171,13 +167,13 @@ public ModelAndView insufficientAuth(final Model model, final HttpServletRequest
return new ModelAndView("iam/insufficient-auth");
}
- @RequestMapping(value = "/registration/submitted", method = RequestMethod.GET)
+ @GetMapping(value = "/registration/submitted")
public ModelAndView submissionSuccess() {
SecurityContextHolder.clearContext();
return new ModelAndView("iam/requestSubmitted");
}
- @RequestMapping(value = "/registration/config", method = RequestMethod.GET)
+ @GetMapping(value = "/registration/config")
public RegistrationProperties registrationConfig() {
return registrationProperties;
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationConverter.java b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationConverter.java
index 85e67ceb0..4978a7557 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationConverter.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationConverter.java
@@ -19,7 +19,6 @@
import java.util.Set;
import java.util.stream.Collectors;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.google.common.collect.Lists;
@@ -37,7 +36,6 @@ public class RegistrationConverter {
final LabelDTOConverter labelConverter;
- @Autowired
public RegistrationConverter(LabelDTOConverter labelConverter) {
this.labelConverter = labelConverter;
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationRequestService.java b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationRequestService.java
index 51256cd60..1a815c8a5 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationRequestService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationRequestService.java
@@ -32,7 +32,7 @@ RegistrationRequestDto createRequest(RegistrationRequestDto request,
RegistrationRequestDto confirmRequest(String confirmationKey);
- RegistrationRequestDto rejectRequest(String requestUuid, Optional motivation);
+ RegistrationRequestDto rejectRequest(String requestUuid, Optional motivation, boolean doNotSendEmail);
RegistrationRequestDto approveRequest(String requestUuid);
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationUtilsController.java b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationUtilsController.java
index 36e7df251..b1a0b49e9 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationUtilsController.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/registration/RegistrationUtilsController.java
@@ -15,11 +15,9 @@
*/
package it.infn.mw.iam.registration;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -28,18 +26,16 @@ public class RegistrationUtilsController {
final RegistrationRequestService service;
- @Autowired
public RegistrationUtilsController(RegistrationRequestService service) {
this.service = service;
}
- @RequestMapping(value = "/registration/username-available/{username:.+}",
- method = RequestMethod.GET)
+ @GetMapping(value = "/registration/username-available/{username:.+}")
public Boolean usernameAvailable(@PathVariable("username") String username) {
return service.usernameAvailable(username);
}
- @RequestMapping(value = "/registration/email-available/{email:.+}", method = RequestMethod.GET)
+ @GetMapping(value = "/registration/email-available/{email:.+}")
public Boolean emailAvailable(@PathVariable("email") String email) {
return service.emailAvailable(email);
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/registration/validation/CernHrDbRequestValidatorService.java b/iam-login-service/src/main/java/it/infn/mw/iam/registration/validation/CernHrDbRequestValidatorService.java
index fa1b36faf..22d09c81e 100644
--- a/iam-login-service/src/main/java/it/infn/mw/iam/registration/validation/CernHrDbRequestValidatorService.java
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/registration/validation/CernHrDbRequestValidatorService.java
@@ -16,14 +16,13 @@
package it.infn.mw.iam.registration.validation;
import static com.google.common.base.Strings.isNullOrEmpty;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_CERN_PREFIX;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX;
import static it.infn.mw.iam.registration.validation.RegistrationRequestValidationResult.error;
import static it.infn.mw.iam.registration.validation.RegistrationRequestValidationResult.invalid;
import static it.infn.mw.iam.registration.validation.RegistrationRequestValidationResult.ok;
import static java.lang.String.format;
import static java.util.Objects.isNull;
-import java.util.Objects;
import java.util.Optional;
import org.slf4j.Logger;
@@ -36,9 +35,11 @@
import it.infn.mw.iam.api.common.LabelDTO;
import it.infn.mw.iam.api.registration.cern.CernHrDBApiService;
import it.infn.mw.iam.api.registration.cern.CernHrDbApiError;
+import it.infn.mw.iam.api.registration.cern.dto.ParticipationDTO;
import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO;
import it.infn.mw.iam.authn.ExternalAuthenticationRegistrationInfo;
import it.infn.mw.iam.config.cern.CernProperties;
+import it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils;
import it.infn.mw.iam.registration.RegistrationRequestDto;
@Service
@@ -101,10 +102,17 @@ public RegistrationRequestValidationResult validateRegistrationRequest(
}
try {
- VOPersonDTO voPersonDTO = hrDbApi.getHrDbPersonRecord(cernPersonId);
- if (hasValidParticipationToExperiment(voPersonDTO)) {
+ Optional voPersonDTO = hrDbApi.getHrDbPersonRecord(cernPersonId);
+ if (voPersonDTO.isEmpty()) {
+ return invalid(format("No experiment participation found for user %s %s (PersonId: %s)",
+ auth.getGivenName(), auth.getFamilyName(), cernPersonId));
+ }
+ Optional ep = CernHrLifecycleUtils.getMostRecentMembership(
+ voPersonDTO.get().getParticipations(), cernProperties.getExperimentName());
+
+ if (ep.isPresent() && CernHrLifecycleUtils.isActiveMembership(ep.get().getEndDate())) {
addPersonIdLabel(registrationRequest, cernPersonId);
- synchronizeInfo(registrationRequest, voPersonDTO);
+ synchronizeInfo(registrationRequest, voPersonDTO.get());
return ok();
}
} catch (CernHrDbApiError e) {
@@ -114,13 +122,4 @@ public RegistrationRequestValidationResult validateRegistrationRequest(
return invalid(format("No valid experiment participation found for user %s %s (PersonId: %s)",
auth.getGivenName(), auth.getFamilyName(), cernPersonId));
}
-
- private boolean hasValidParticipationToExperiment(VOPersonDTO voPersonDTO) {
- if (Objects.isNull(voPersonDTO)) {
- return false;
- }
- return voPersonDTO.getParticipations()
- .stream()
- .anyMatch(p -> p.getExperiment().equalsIgnoreCase(cernProperties.getExperimentName()));
- }
}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionHelper.java
new file mode 100644
index 000000000..78f0590dc
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionHelper.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.util.mfa;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+public class IamTotpMfaEncryptionAndDecryptionHelper {
+
+ public enum AesCipherModes {
+ CBC("AES/CBC/PKCS5Padding"),
+ GCM("AES/GCM/NoPadding");
+
+ private final String cipherMode;
+
+ AesCipherModes(String cipherMode) {
+ this.cipherMode = cipherMode;
+ }
+
+ public String getCipherMode() {
+ return cipherMode;
+ }
+ }
+
+ private String encryptionAlgorithm = "AES";
+ private AesCipherModes shortFormOfCipherMode = AesCipherModes.GCM;
+ private String modeOfOperation = shortFormOfCipherMode.getCipherMode();
+
+ // AES `keySize` has 3 options: 128, 192, or 256 bits.
+ private int keyLengthInBits = 128;
+
+ private int ivLengthInBytes = 16;
+ private int tagLengthInBits = 128;
+ private int ivLengthInBytesForGCM = 12;
+
+ // Multiples of 8
+ private int saltLengthInBytes = 16;
+
+ // The higher value the better
+ private int iterations = 65536;
+ private Charset utf8 = StandardCharsets.UTF_8;
+
+ private static IamTotpMfaEncryptionAndDecryptionHelper instance;
+
+ private IamTotpMfaEncryptionAndDecryptionHelper() {
+ // Prevent instantiation
+ }
+
+ public String getEncryptionAlgorithm() {
+ return encryptionAlgorithm;
+ }
+
+ public void setEncryptionAlgorithm(String encryptionAlgorithm) {
+ this.encryptionAlgorithm = encryptionAlgorithm;
+ }
+
+ public String getModeOfOperation() {
+ return modeOfOperation;
+ }
+
+ public void setModeOfOperation(String modeOfOperation) {
+ this.modeOfOperation = modeOfOperation;
+ }
+
+ public int getKeyLengthInBits() {
+ return keyLengthInBits;
+ }
+
+ public void setKeyLengthInBits(int keyLengthInBits) {
+ this.keyLengthInBits = keyLengthInBits;
+ }
+
+ public int getIvLengthInBytes() {
+ return ivLengthInBytes;
+ }
+
+ public void setIvLengthInBytes(int ivLengthInBytes) {
+ this.ivLengthInBytes = ivLengthInBytes;
+ }
+
+ public int getTagLengthInBits() {
+ return tagLengthInBits;
+ }
+
+ public void setTagLengthInBits(int tagLengthInBits) {
+ this.tagLengthInBits = tagLengthInBits;
+ }
+
+ public int getIvLengthInBytesForGCM() {
+ return ivLengthInBytesForGCM;
+ }
+
+ public void setIvLengthInBytesForGCM(int ivLengthInBytesForGCM) {
+ this.ivLengthInBytesForGCM = ivLengthInBytesForGCM;
+ }
+
+ public int getSaltLengthInBytes() {
+ return saltLengthInBytes;
+ }
+
+ public void setSaltLengthInBytes(int saltLengthInBytes) {
+ this.saltLengthInBytes = saltLengthInBytes;
+ }
+
+ public int getIterations() {
+ return iterations;
+ }
+
+ public void setIterations(int iterations) {
+ this.iterations = iterations;
+ }
+
+ public Charset getUtf8() {
+ return utf8;
+ }
+
+ public AesCipherModes getShortFormOfCipherMode() {
+ return shortFormOfCipherMode;
+ }
+
+ public void setShortFormOfCipherMode(AesCipherModes shortFormOfCipherMode) {
+ this.shortFormOfCipherMode = shortFormOfCipherMode;
+ }
+
+ /**
+ * Helper to get the instance instead of creating new objects,
+ * acts like a singleton pattern.
+ */
+ public static synchronized IamTotpMfaEncryptionAndDecryptionHelper getInstance() {
+ if (instance == null) {
+ instance = new IamTotpMfaEncryptionAndDecryptionHelper();
+ }
+
+ return instance;
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionUtil.java b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionUtil.java
new file mode 100644
index 000000000..e53b6e859
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaEncryptionAndDecryptionUtil.java
@@ -0,0 +1,245 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.util.mfa;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.security.Key;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.util.Base64;
+import java.nio.ByteBuffer;
+
+public class IamTotpMfaEncryptionAndDecryptionUtil {
+
+ private static final IamTotpMfaEncryptionAndDecryptionHelper defaultModel = IamTotpMfaEncryptionAndDecryptionHelper
+ .getInstance();
+
+ private IamTotpMfaEncryptionAndDecryptionUtil() {
+ }
+
+ /**
+ * This helper method requires a password for encrypting the plaintext.
+ * Ensure to use the same password for decryption as well.
+ *
+ * @param plaintext plaintext to encrypt.
+ * @param password Provided by the admin through the environment
+ * variable.
+ *
+ * @return String If encryption is successful, the cipherText would be returned.
+ *
+ * @throws IamTotpMfaInvalidArgumentError
+ */
+ public static String encryptSecret(String plaintext, String password)
+ throws IamTotpMfaInvalidArgumentError {
+ String modeOfOperation = defaultModel.getModeOfOperation();
+
+ if (validateText(plaintext) || validateText(password)) {
+ throw new IamTotpMfaInvalidArgumentError(
+ "Please ensure that you provide plaintext and the password");
+ }
+
+ try {
+ byte[] salt = generateNonce(defaultModel.getSaltLengthInBytes());
+ Key key = getKeyFromPassword(password, salt, defaultModel.getEncryptionAlgorithm());
+ byte[] iv;
+
+ Cipher cipher = Cipher.getInstance(modeOfOperation);
+
+ if (isCipherModeCBC()) {
+ IvParameterSpec ivParamSpec = getIVSecureRandom(defaultModel.getIvLengthInBytes());
+
+ cipher.init(Cipher.ENCRYPT_MODE, key, ivParamSpec);
+
+ iv = cipher.getIV();
+ } else {
+ iv = generateNonce(defaultModel.getIvLengthInBytesForGCM());
+
+ cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(defaultModel.getTagLengthInBits(), iv));
+ }
+
+ byte[] cipherText = cipher.doFinal(plaintext.getBytes());
+
+ // Append salt, IV, and cipherText into `encryptedData`.
+ byte[] encryptedData = ByteBuffer.allocate(salt.length + iv.length + cipherText.length)
+ .put(salt)
+ .put(iv)
+ .put(cipherText)
+ .array();
+
+ return Base64.getEncoder()
+ .encodeToString(encryptedData);
+ } catch (Exception exp) {
+ throw new IamTotpMfaInvalidArgumentError(
+ "An error occurred while encrypting secret", exp);
+ }
+ }
+
+ /**
+ * Helper to decrypt the cipherText. Ensure you use the same password as you did
+ * during encryption.
+ *
+ * @param cText Encrypted data which help us to extract the plaintext.
+ * @param password Provided by the admin through the environment
+ * variable.
+ *
+ * @return String Returns plainText which we obtained from the cipherText.
+ *
+ * @throws IamTotpMfaInvalidArgumentError
+ */
+ public static String decryptSecret(String cText, String password)
+ throws IamTotpMfaInvalidArgumentError {
+ String modeOfOperation = defaultModel.getModeOfOperation();
+
+ if (validateText(cText) || validateText(password)) {
+ throw new IamTotpMfaInvalidArgumentError(
+ "Please ensure that you provide cipherText and the password");
+ }
+
+ try {
+ byte[] encryptedData = Base64.getDecoder().decode(cText);
+
+ ByteBuffer byteBuffer = ByteBuffer.wrap(encryptedData);
+
+ // Extract salt, IV, and cipherText from the combined data
+ byte[] salt = new byte[defaultModel.getSaltLengthInBytes()];
+ byteBuffer.get(salt);
+
+ byte[] iv;
+
+ if (isCipherModeCBC()) {
+ iv = new byte[defaultModel.getIvLengthInBytes()];
+ } else {
+ iv = new byte[defaultModel.getIvLengthInBytesForGCM()];
+ }
+
+ byteBuffer.get(iv);
+
+ byte[] cipherText = new byte[byteBuffer.remaining()];
+ byteBuffer.get(cipherText);
+
+ Key key = getKeyFromPassword(password, salt, defaultModel.getEncryptionAlgorithm());
+
+ Cipher cipher = Cipher.getInstance(modeOfOperation);
+
+ if (isCipherModeCBC()) {
+ cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
+ } else {
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(defaultModel.getTagLengthInBits(), iv));
+ }
+
+ byte[] decryptedTextBytes = cipher.doFinal(cipherText);
+
+ return new String(decryptedTextBytes);
+ } catch (Exception exp) {
+ throw new IamTotpMfaInvalidArgumentError(
+ "An error occurred while decrypting ciphertext", exp);
+ }
+ }
+
+ /**
+ * Generates a random Initialization Vector(IV) using a secure random generator.
+ *
+ * @param byteSize. Specifies IV length for CBC.
+ *
+ * @return IvParameterSpec
+ *
+ * @throws NoSuchAlgorithmException
+ */
+ private static IvParameterSpec getIVSecureRandom(int byteSize)
+ throws NoSuchAlgorithmException {
+ SecureRandom random = SecureRandom.getInstanceStrong();
+ byte[] iv = new byte[byteSize];
+
+ random.nextBytes(iv);
+
+ return new IvParameterSpec(iv);
+ }
+
+ /**
+ * Generates the key which can be used to encrypt and decrypt the plaintext.
+ *
+ * @param password Provided by the admin through the environment
+ * variable.
+ * @param salt Ensures derived keys to be different.
+ * @param algorithm A symmetric key algorithm (AES) has been used.
+ *
+ * @return SecretKey
+ *
+ * @throws NoSuchAlgorithmException
+ * @throws InvalidKeySpecException
+ */
+ private static SecretKey getKeyFromPassword(String password, byte[] salt, String algorithm)
+ throws NoSuchAlgorithmException, InvalidKeySpecException {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, defaultModel.getIterations(),
+ defaultModel.getKeyLengthInBits());
+
+ byte[] calculatedHash = factory.generateSecret(spec).getEncoded();
+ byte[] storedHash = factory.generateSecret(spec).getEncoded();
+
+ if (MessageDigest.isEqual(calculatedHash, storedHash)) {
+ return new SecretKeySpec(calculatedHash, algorithm);
+ } else {
+ throw new IamTotpMfaInvalidArgumentError("Invalid password");
+ }
+ }
+
+ /**
+ * Generates a random salt using a secure random generator.
+ *
+ * @param byteSize Specifies either salt or IV for GCM byte length
+ *
+ * @return byte[]
+ * @throws NoSuchAlgorithmException
+ */
+ private static byte[] generateNonce(int byteSize) throws NoSuchAlgorithmException {
+ SecureRandom random = SecureRandom.getInstanceStrong();
+ byte[] salt = new byte[byteSize];
+
+ random.nextBytes(salt);
+
+ return salt;
+ }
+
+ /**
+ * Helper method to determine whether the provided text is an empty or NULL.
+ *
+ * @return boolean
+ */
+ private static boolean validateText(String text) {
+ return (text == null || text.isEmpty());
+ }
+
+ /**
+ * Helper method to determine whether it is in CBC mode or NOT.
+ *
+ * @return boolean
+ */
+ private static boolean isCipherModeCBC() {
+ return defaultModel.getModeOfOperation().equalsIgnoreCase(
+ IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.CBC.getCipherMode());
+ }
+}
diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaInvalidArgumentError.java b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaInvalidArgumentError.java
new file mode 100644
index 000000000..34b7b12c3
--- /dev/null
+++ b/iam-login-service/src/main/java/it/infn/mw/iam/util/mfa/IamTotpMfaInvalidArgumentError.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.util.mfa;
+
+public class IamTotpMfaInvalidArgumentError extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public IamTotpMfaInvalidArgumentError(String message) {
+ super(message);
+ }
+
+ public IamTotpMfaInvalidArgumentError(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/iam-login-service/src/main/resources/application-cern.yml b/iam-login-service/src/main/resources/application-cern.yml
new file mode 100644
index 000000000..c8ceb6767
--- /dev/null
+++ b/iam-login-service/src/main/resources/application-cern.yml
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+
+cern:
+ sso-issuer: "https://auth.cern.ch/auth/realms/cern"
+ person-id-claim: "cern_person_id"
+ experiment-name: "test"
+ hr-api:
+ url: "http://hr.test.example"
+ username: "username"
+ password: "password"
+ task:
+ enabled: true
+ cron-schedule: "0 23 */12 * * *"
+ page-size: 50
+ # Action when API returns 404 on asking info about a VO person.
+ # Values: no_action, disable_user
+ on-person-id-not-found: no_action
+ # Action when the VO-person received from the HR API contains no participations to the experiment (even expired).
+ # Values: no_action, disable_user
+ on-participation-not-found: no_action
\ No newline at end of file
diff --git a/iam-login-service/src/main/resources/application-h2.yml b/iam-login-service/src/main/resources/application-h2.yml
index f3128b92c..99fcfa7c4 100644
--- a/iam-login-service/src/main/resources/application-h2.yml
+++ b/iam-login-service/src/main/resources/application-h2.yml
@@ -27,7 +27,7 @@ spring:
locations:
- classpath:db/migration/h2
- classpath:db/migration/test
-
+
datasource:
type: org.h2.jdbcx.JdbcDataSource
url: jdbc:h2:mem:iam;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1
diff --git a/iam-login-service/src/main/resources/application-mfa.yml b/iam-login-service/src/main/resources/application-mfa.yml
new file mode 100644
index 000000000..68a1a018b
--- /dev/null
+++ b/iam-login-service/src/main/resources/application-mfa.yml
@@ -0,0 +1,19 @@
+#
+# Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+mfa:
+ multi-factor-settings-btn-enabled: ${IAM_TOTP_MFA_ENABLE_MFA_SETTINGS_BUTTON:true}
+ password-to-encrypt-and-decrypt: ${IAM_TOTP_MFA_PASSWORD_TO_ENCRYPT_AND_DECRYPT:define_me_please}
diff --git a/iam-login-service/src/main/resources/application-prod.yml b/iam-login-service/src/main/resources/application-prod.yml
index f1e9582b7..845329669 100644
--- a/iam-login-service/src/main/resources/application-prod.yml
+++ b/iam-login-service/src/main/resources/application-prod.yml
@@ -36,4 +36,4 @@ spring:
hikari:
maximum-pool-size: ${IAM_DB_MAX_ACTIVE:50}
minimum-idle: ${IAM_DB_MIN_IDLE:8}
- connection-test-query: ${IAM_DB_VALIDATION_QUERY:SELECT 1}
\ No newline at end of file
+ connection-test-query: ${IAM_DB_VALIDATION_QUERY:SELECT 1}
diff --git a/iam-login-service/src/main/resources/application.properties b/iam-login-service/src/main/resources/application.properties
index 696ab3e43..3face04bf 100644
--- a/iam-login-service/src/main/resources/application.properties
+++ b/iam-login-service/src/main/resources/application.properties
@@ -39,7 +39,6 @@ logging.level.org.apache.tomcat.util.scan.StandardJarScanner=ERROR
# Persistence engine logging
logging.level.org.eclipse.persistence=DEBUG
-
# Notification service logging
#logging.level.it.infn.mw.iam.notification=DEBUG
diff --git a/iam-login-service/src/main/resources/application.yml b/iam-login-service/src/main/resources/application.yml
index 76083b81d..433d5a5cb 100644
--- a/iam-login-service/src/main/resources/application.yml
+++ b/iam-login-service/src/main/resources/application.yml
@@ -25,6 +25,7 @@ server:
port: ${IAM_PORT:8080}
tomcat:
+
accesslog:
enabled: ${IAM_TOMCAT_ACCESS_LOG_ENABLED:false}
directory: ${IAM_TOMCAT_ACCESS_LOG_DIRECTORY:/tmp}
diff --git a/iam-login-service/src/main/resources/email-templates/mfaDisable.ftl b/iam-login-service/src/main/resources/email-templates/mfaDisable.ftl
new file mode 100644
index 000000000..7eabd5b8f
--- /dev/null
+++ b/iam-login-service/src/main/resources/email-templates/mfaDisable.ftl
@@ -0,0 +1,15 @@
+Dear ${recipient},
+
+this mail is to inform that your Multi-Factor Authentication (MFA) in ${organisationName} has been successfully disabled.
+As a result, you can now delete the existing entry from your authenticator.
+
+
+To ensure the security of your account, please follow these steps:
+1. Open your authenticator.
+2. Locate the entry associated with our service.
+3. Delete the entry.
+
+If you have any questions or need further assistance, please do not hesitate to contact our support team.
+
+
+The ${organisationName} management service
diff --git a/iam-login-service/src/main/resources/email-templates/mfaEnable.ftl b/iam-login-service/src/main/resources/email-templates/mfaEnable.ftl
new file mode 100644
index 000000000..9fb835af3
--- /dev/null
+++ b/iam-login-service/src/main/resources/email-templates/mfaEnable.ftl
@@ -0,0 +1,10 @@
+Dear ${recipient},
+
+this mail is to inform that your Multi-Factor Authentication (MFA) in ${organisationName} has been successfully enabled.
+
+If you have any questions or need further assistance, please do not hesitate to contact our support team.
+
+If you encounter issues with your authenticator, please contact the Administrator to request MFA deactivation.
+
+
+The ${organisationName} management service
diff --git a/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag b/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag
index 937d331b2..e6ca98319 100644
--- a/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag
+++ b/iam-login-service/src/main/webapp/WEB-INF/tags/iamHeader.tag
@@ -119,4 +119,8 @@ function getRefreshTokenValiditySeconds() {
function getClientTrackLastUsed() {
return ${clientTrackLastUsed};
}
+
+function getMfaSettingsBtnEnabled() {
+ return ${loginPageConfiguration.mfaSettingsBtnEnabled};
+}
diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp
new file mode 100644
index 000000000..8ab0068db
--- /dev/null
+++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/authenticator-app/verify-authenticator-app-form.jsp
@@ -0,0 +1,42 @@
+<%--
+
+ Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+--%>
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/confirmRequest.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/confirmRequest.jsp
new file mode 100644
index 000000000..22ee27cae
--- /dev/null
+++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/confirmRequest.jsp
@@ -0,0 +1,33 @@
+<%--
+
+ 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="c" uri="http://java.sun.com/jsp/jstl/core"%>
+<%@ taglib prefix="t" tagdir="/WEB-INF/tags/iam"%>
+
+
Verify registration request
+
+
In order to proceed with the registration request, please confirm
+
+
+
+
+
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp b/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp
index a9bf2d60d..deebe2572 100644
--- a/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp
+++ b/iam-login-service/src/main/webapp/WEB-INF/views/iam/dashboard.jsp
@@ -92,6 +92,7 @@
+
@@ -115,11 +116,14 @@
+
-
+
+
+
@@ -136,6 +140,10 @@
+
+
+
+
+
+
+
Created
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js
index 93a52e6bc..e32e6ea8b 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/detail/user.detail.component.js
@@ -44,6 +44,10 @@
return self.indigoUser() && self.indigoUser().endTime;
};
+ self.isMfaSettingsBtnEnabled = function () {
+ return Utils.isMfaSettingsBtnEnabled();
+ };
+
}
angular.module('dashboardApp').component('userDetail', {
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html
new file mode 100644
index 000000000..5b37667f8
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html
@@ -0,0 +1,23 @@
+
+
+
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.js
new file mode 100644
index 000000000..822c52413
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.js
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+ 'use strict';
+
+ function DisableMfaController(
+ toaster, Utils, ModalService, $uibModal) {
+ var self = this;
+
+ self.$onInit = function () {
+ console.log('DisableMfaController onInit');
+ self.enabled = true;
+ self.user = self.userCtrl.user;
+ };
+
+ self.isMe = function() { return self.userCtrl.isMe(); };
+ self.isVoAdmin = function () { return self.userCtrl.isVoAdmin(); };
+
+ self.openDisableMfaModal = function() {
+ var modalInstance = $uibModal.open({
+ templateUrl: '/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html',
+ controller: 'DisableMfaController',
+ controllerAs: 'disableMfaCtrl',
+ resolve: {user: function() { return self.user; }}
+ });
+
+ modalInstance.result.then(function(msg) {
+ self.userCtrl.loadUser().then(function () {
+ toaster.pop({
+ type: 'success',
+ body: msg
+ });
+ });
+ });
+ };
+ }
+
+
+
+ angular.module('dashboardApp').component('userDisableMfa', {
+ require: {userCtrl: '^user'},
+ templateUrl:
+ '/resources/iam/apps/dashboard-app/components/user/disable-mfa/user.disable-mfa.component.html',
+ controller: [
+ 'toaster', 'Utils', 'ModalService', '$uibModal',
+ DisableMfaController
+ ]
+ });
+})();
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html
new file mode 100644
index 000000000..7bdff3602
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html
@@ -0,0 +1,23 @@
+
+
+
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js
new file mode 100644
index 000000000..8a8da5262
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.js
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function() {
+ 'use strict';
+
+ function EditMfaController(
+ toaster, Utils, ModalService, $uibModal) {
+ var self = this;
+
+ self.$onInit = function() {
+ console.log('EditMfaController onInit');
+ self.enabled = true;
+ self.user = self.userCtrl.user;
+ };
+
+ self.isMe = function() { return self.userCtrl.isMe(); };
+
+ self.isMfaActive = function() { return self.userCtrl.user.isMfaActive; };
+
+ self.openUserMfaModal = function() {
+ var modalInstance = $uibModal.open({
+ templateUrl: '/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html',
+ controller: 'UserMfaController',
+ controllerAs: 'userMfaCtrl',
+ resolve: {user: function() { return self.user; }}
+ });
+
+ modalInstance.result.then(function (msg) {
+ self.userCtrl.loadUser().then(function () {
+ toaster.pop({
+ type: 'success',
+ body: msg
+ });
+ });
+ });
+ };
+ }
+
+
+
+ angular.module('dashboardApp').component('userMfa', {
+ require: {userCtrl: '^user'},
+ templateUrl:
+ '/resources/iam/apps/dashboard-app/components/user/mfa/user.mfa.component.html',
+ controller: [
+ 'toaster', 'Utils', 'ModalService', '$uibModal',
+ EditMfaController
+ ]
+ });
+})();
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html
index 4a1b32498..04f8db5d9 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.html
@@ -68,13 +68,18 @@
-
+
+
+
+
+
+
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js
index 6aa8ea1fd..b3b8f2e8b 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/user/user.component.js
@@ -34,6 +34,10 @@
return Utils.isAdmin();
};
+ self.isMfaSettingsBtnEnabled = function () {
+ return Utils.isMfaSettingsBtnEnabled();
+ };
+
self.isGroupManager = function () {
return Utils.isGroupManager();
};
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js
new file mode 100644
index 000000000..f678ee8b2
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/authenticator-app.controller.js
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+(function () {
+ 'use strict';
+
+ angular.module('dashboardApp')
+ .controller('EnableAuthenticatorAppController', EnableAuthenticatorAppController);
+
+ angular.module('dashboardApp')
+ .controller('DisableAuthenticatorAppController', DisableAuthenticatorAppController);
+
+ EnableAuthenticatorAppController.$inject = [
+ '$scope', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user', '$uibModal'
+ ];
+
+ DisableAuthenticatorAppController.$inject = [
+ '$scope', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user'
+ ];
+
+ function EnableAuthenticatorAppController(
+ $scope, $uibModalInstance, Utils, AuthenticatorAppService, user, $uibModal) {
+ var authAppCtrl = this;
+
+ authAppCtrl.user = {
+ ...user,
+ code: ''
+ };
+
+ authAppCtrl.$onInit = function () {
+ AuthenticatorAppService.addMfaSecretToUser().then(function (response) {
+ authAppCtrl.secret = response.data.secret;
+ authAppCtrl.dataUri = response.data.dataUri;
+ });
+ }
+
+ authAppCtrl.codeMinlength = 6;
+ authAppCtrl.requestPending = false;
+
+ authAppCtrl.dismiss = dismiss;
+ authAppCtrl.reset = reset;
+
+ function reset() {
+ console.log('reset form');
+
+ authAppCtrl.user.code = '';
+
+ if ($scope.authenticatorAppForm) {
+ $scope.authenticatorAppForm.$setPristine();
+ }
+
+ authAppCtrl.requestPending = false;
+ }
+
+ authAppCtrl.reset();
+
+ function dismiss() { return $uibModalInstance.dismiss('Cancel'); }
+
+ authAppCtrl.message = '';
+
+ authAppCtrl.clearError = function () {
+ $scope.operationResult = null;
+ };
+
+ authAppCtrl.submitEnable = function () {
+ authAppCtrl.requestPending = true;
+ AuthenticatorAppService
+ .enableAuthenticatorApp(
+ authAppCtrl.user.code)
+ .then(function () {
+ authAppCtrl.requestPending = false;
+ $uibModalInstance.close('Authenticator enabled');
+ })
+ .catch(function (error) {
+ authAppCtrl.requestPending = false;
+ $scope.operationResult = Utils.buildErrorResult(error.data.error);
+ authAppCtrl.reset();
+ });
+ };
+ }
+
+ function DisableAuthenticatorAppController(
+ $scope, $uibModalInstance, Utils, AuthenticatorAppService, user) {
+ var authAppCtrl = this;
+
+ authAppCtrl.user = {
+ ...user,
+ code: ''
+ };
+
+ authAppCtrl.codeMinlength = 6;
+ authAppCtrl.requestPending = false;
+
+ authAppCtrl.dismiss = dismiss;
+ authAppCtrl.reset = reset;
+
+ function reset() {
+ console.log('reset form');
+
+ authAppCtrl.user.code = '';
+
+ if ($scope.authenticatorAppForm) {
+ $scope.authenticatorAppForm.$setPristine();
+ }
+
+ authAppCtrl.requestPending = false;
+ }
+
+ authAppCtrl.reset();
+
+ function dismiss() { return $uibModalInstance.dismiss('Cancel'); }
+
+ authAppCtrl.message = '';
+
+ authAppCtrl.clearError = function () {
+ $scope.operationResult = null;
+ };
+
+ authAppCtrl.submitDisable = function () {
+ authAppCtrl.requestPending = true;
+ AuthenticatorAppService
+ .disableAuthenticatorApp(
+ authAppCtrl.user.code)
+ .then(function () {
+ authAppCtrl.requestPending = false;
+ return $uibModalInstance.close('Authenticator disabled');
+ })
+ .catch(function (error) {
+ authAppCtrl.requestPending = false;
+ $scope.operationResult = Utils.buildErrorResult(error.data.error);
+ authAppCtrl.reset();
+ });
+ };
+ }
+
+})();
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/disable-mfa.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/disable-mfa.controller.js
new file mode 100644
index 000000000..2fdf4af44
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/disable-mfa.controller.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+'use strict';
+
+angular.module('dashboardApp')
+ .controller('DisableMfaController', DisableMfaController);
+
+DisableMfaController.$inject = [
+ '$http', '$scope', '$state', '$uibModalInstance', 'Utils', 'AuthenticatorAppService', 'user', '$uibModal', 'toaster'
+];
+
+function DisableMfaController(
+ $http, $scope, $state, $uibModalInstance, Utils, AuthenticatorAppService, user, $uibModal, toaster) {
+ var disableMfaCtrl = this;
+
+ disableMfaCtrl.userToEdit = user;
+
+ disableMfaCtrl.disableMfa = function () {
+ AuthenticatorAppService.disableAuthenticatorAppForUser(user.id).then(function(result) {
+ if (result != null && result.status === 200) {
+ $uibModalInstance.close('Multi-factor authentication disabled');
+ } else {
+ var message = "Unable to disable multi-factor authentication";
+ console.error(message);
+ $uibModalInstance.close(message);
+ }
+ }).catch(function(error) {
+ console.error(error);
+ toaster.pop({ type: 'error', body: error.data.error });
+ $uibModalInstance.dismiss();
+ });
+ };
+
+ disableMfaCtrl.cancel = function () { return $uibModalInstance.close('Cancel'); };
+
+}
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js
new file mode 100644
index 000000000..59df73a4a
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/controllers/user-mfa.controller.js
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+'use strict';
+
+angular.module('dashboardApp')
+ .controller('UserMfaController', UserMfaController);
+
+UserMfaController.$inject = [
+ '$http', '$scope', '$state', '$uibModalInstance', 'Utils', 'user', '$uibModal', 'toaster'
+];
+
+function UserMfaController(
+ $http, $scope, $state, $uibModalInstance, Utils, user, $uibModal, toaster) {
+ var userMfaCtrl = this;
+
+ userMfaCtrl.$onInit = function() {
+ console.log('UserMfaController onInit');
+ getMfaSettings();
+ };
+
+ // TODO include this data in what is fetched from the /scim/me endpoint
+ function getMfaSettings() {
+ $http.get('/iam/multi-factor-settings').then(function(response) {
+ userMfaCtrl.authenticatorAppActive = response.data.authenticatorAppActive;
+ });
+ }
+
+ userMfaCtrl.userToEdit = user;
+
+ userMfaCtrl.enableAuthenticatorApp = enableAuthenticatorApp;
+ userMfaCtrl.disableAuthenticatorApp = disableAuthenticatorApp;
+
+ function enableAuthenticatorApp() {
+ var modalInstance = $uibModal.open({
+ templateUrl: '/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html',
+ controller: 'EnableAuthenticatorAppController',
+ controllerAs: 'authAppCtrl',
+ resolve: { user: function() { return self.user; } }
+ });
+
+ modalInstance.result.then(function(msg) {
+ return $uibModalInstance.close(msg);
+ });
+ }
+
+ function disableAuthenticatorApp() {
+ var modalInstance = $uibModal.open({
+ templateUrl: '/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html',
+ controller: 'DisableAuthenticatorAppController',
+ controllerAs: 'authAppCtrl',
+ resolve: { user: function() { return self.user; } }
+ });
+
+ modalInstance.result.then(function(msg) {
+ return $uibModalInstance.close(msg);
+ });
+ }
+
+ userMfaCtrl.dismiss = dismiss;
+ userMfaCtrl.reset = reset;
+
+ function reset() {
+ console.log('reset form');
+
+ userMfaCtrl.enabled = true;
+
+ if ($scope.userMfaForm) {
+ $scope.userMfaForm.$setPristine();
+ }
+ }
+
+ userMfaCtrl.reset();
+
+ function dismiss() { return $uibModalInstance.dismiss('Cancel'); }
+
+ userMfaCtrl.message = '';
+
+ userMfaCtrl.submit = function() {
+ return $uibModalInstance.close('Updated settings');
+ };
+}
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js
new file mode 100644
index 000000000..aa204bec8
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/authenticator-app.service.js
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+'use strict'
+
+angular.module('dashboardApp').factory('AuthenticatorAppService', AuthenticatorAppService);
+
+AuthenticatorAppService.$inject = ['$http', '$httpParamSerializerJQLike'];
+
+function AuthenticatorAppService($http, $httpParamSerializerJQLike) {
+
+ var service = {
+ addMfaSecretToUser: addMfaSecretToUser,
+ enableAuthenticatorApp: enableAuthenticatorApp,
+ disableAuthenticatorApp: disableAuthenticatorApp,
+ disableAuthenticatorAppForUser: disableAuthenticatorAppForUser,
+ getMfaSettings: getMfaSettings,
+ getMfaSettingsForAccount: getMfaSettingsForAccount
+ };
+
+ return service;
+
+ function addMfaSecretToUser() {
+ return $http.put('/iam/authenticator-app/add-secret');
+ }
+
+ function enableAuthenticatorApp(code) {
+
+ var data = $httpParamSerializerJQLike({
+ code: code
+ });
+
+ var config = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ };
+
+ return $http.post('/iam/authenticator-app/enable', data, config);
+ };
+
+ function disableAuthenticatorApp(code) {
+
+ var data = $httpParamSerializerJQLike({
+ code: code
+ });
+
+ var config = {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ };
+
+ return $http.post('/iam/authenticator-app/disable', data, config);
+ };
+
+ function disableAuthenticatorAppForUser(userId) {
+ return $http.delete('/iam/authenticator-app/reset/' + userId);
+ }
+
+ function handleSuccess(res) {
+ return res.data.authenticatorAppActive;
+ }
+
+ function handleError(res) {
+ return $q.reject(res);
+ }
+
+ function getMfaSettingsForAccount(userId) {
+ return $http.get('/iam/multi-factor-settings/' + userId).then(handleSuccess).catch(handleError);
+ }
+
+ function getMfaSettings() {
+ return $http.get('/iam/multi-factor-settings/').then(handleSuccess).catch(handleError);
+ }
+}
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js
index 7e84d76ea..53da640cc 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/load-templates.service.js
@@ -26,8 +26,11 @@
'/resources/iam/apps/dashboard-app/templates/common/userinfo-box.html',
'/resources/iam/apps/dashboard-app/templates/header.html',
'/resources/iam/apps/dashboard-app/templates/home/account-link-dialog.html',
+ '/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html',
+ '/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html',
'/resources/iam/apps/dashboard-app/templates/home/editpassword.html',
'/resources/iam/apps/dashboard-app/templates/home/edituser.html',
+ '/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html',
'/resources/iam/apps/dashboard-app/templates/home/home.html',
'/resources/iam/apps/dashboard-app/templates/loading-modal.html',
'/resources/iam/apps/dashboard-app/templates/nav.html',
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/registration.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/registration.service.js
index 8d784959d..12766877d 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/registration.service.js
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/registration.service.js
@@ -60,10 +60,11 @@
return $http.post('/registration/approve/' + req.uuid);
}
- function rejectRequest(req, motivation) {
+ function rejectRequest(req, motivation, doNotSendEmail) {
var d = {
- motivation: motivation
+ motivation: motivation,
+ doNotSendEmail: doNotSendEmail
};
return $http({
@@ -82,9 +83,9 @@
return $q.all(promises);
}
- function bulkReject(requests, motivation) {
+ function bulkReject(requests, motivation, doNotSendEmail) {
var promises = [];
- angular.forEach(requests, r => promises.push(rejectRequest(r, motivation)));
+ angular.forEach(requests, r => promises.push(rejectRequest(r, motivation, doNotSendEmail)));
return $q.all(promises);
}
}
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js
index 223cf2f0b..e2f794aaa 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/user.service.js
@@ -17,9 +17,9 @@
angular.module('dashboardApp').factory('UserService', UserService);
-UserService.$inject = ['$q', '$rootScope', 'scimFactory', 'Authorities', 'Utils', 'AupService', 'UsersService', 'GroupsService'];
+UserService.$inject = ['$q', '$rootScope', 'scimFactory', 'Authorities', 'Utils', 'AupService', 'UsersService', 'GroupsService', 'AuthenticatorAppService'];
-function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService, UsersService, GroupsService) {
+function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService, UsersService, GroupsService, AuthenticatorAppService) {
var service = {
getUser: getUser,
getMe: getMe,
@@ -38,7 +38,7 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService
function getMe() {
return $q.all([getMeAndAuthorities(),
- AupService.getAupSignature()
+ AupService.getAupSignature(), AuthenticatorAppService.getMfaSettings()
]).then(
function (result) {
var user = result[0];
@@ -51,7 +51,9 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService
} else {
user.aupSignature = null;
}
-
+ if (result[2] !== null) {
+ user.isMfaActive = result[2];
+ }
return user;
}).catch(function (error) {
console.error('Error loading authenticated user information: ', error);
@@ -61,7 +63,9 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService
function getUser(userId) {
return $q
- .all([scimFactory.getUser(userId), Authorities.getAuthorities(userId), AupService.getAupSignatureForUser(userId)])
+ .all([scimFactory.getUser(userId), Authorities.getAuthorities(userId), AupService.getAupSignatureForUser(userId),
+ AuthenticatorAppService.getMfaSettingsForAccount(userId)
+ ])
.then(function (result) {
var user = result[0].data;
user.authorities = result[1].data.authorities;
@@ -74,6 +78,10 @@ function UserService($q, $rootScope, scimFactory, Authorities, Utils, AupService
} else {
user.aupSignature = null;
}
+
+ if (result[3] !== null) {
+ user.isMfaActive = result[3];
+ }
return user;
})
.catch(function (error) {
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js
index bbe64d850..e6a8afb24 100644
--- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/services/utils.service.js
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-(function() {
+(function () {
'use strict';
@@ -29,6 +29,7 @@
isMe: isMe,
isAdmin: isAdmin,
isUser: isUser,
+ isPreAuthenticated: isPreAuthenticated,
getLoggedUser: getLoggedUser,
isRegistrationEnabled: isRegistrationEnabled,
isOidcEnabled: isOidcEnabled,
@@ -40,7 +41,8 @@
isGroupManagerForGroup: isGroupManagerForGroup,
isGroupManager: isGroupManager,
isGroupMember: isGroupMember,
- username: username
+ username: username,
+ isMfaSettingsBtnEnabled: isMfaSettingsBtnEnabled
};
return service;
@@ -86,6 +88,11 @@
return (getUserAuthorities().indexOf("ROLE_USER") != -1);
}
+ function isPreAuthenticated() {
+
+ return (getUserAuthorities().indexOf("ROLE_PRE_AUTHENTICATED") != -1);
+ }
+
function isGroupManager() {
const hasGmAuth = getUserAuthorities().filter((c) => c.startsWith('ROLE_GM:'));
@@ -115,6 +122,10 @@
return getSamlEnabled();
}
+ function isMfaSettingsBtnEnabled() {
+ return getMfaSettingsBtnEnabled();
+ }
+
function buildErrorResult(errorString) {
return {
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html
new file mode 100644
index 000000000..f61a54723
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disable-authenticator-app.html
@@ -0,0 +1,50 @@
+
+
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html
new file mode 100644
index 000000000..0f19e12e3
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/disableMfaSettings.html
@@ -0,0 +1,37 @@
+
+
+
+
Disable multi-factor authentication (MFA)
+ for {{disableMfaCtrl.userToEdit.name.formatted}}
+
+
+
+
+
+ Are you sure you want to disable multi-factor authentication (MFA)
+ for {{disableMfaCtrl.userToEdit.name.formatted}}?
+
+
+
+
\ No newline at end of file
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html
new file mode 100644
index 000000000..bb58c1c29
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/editmfasettings.html
@@ -0,0 +1,59 @@
+
+
diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html
new file mode 100644
index 000000000..5ede93c52
--- /dev/null
+++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/templates/home/enable-authenticator-app.html
@@ -0,0 +1,53 @@
+
+
diff --git a/iam-login-service/src/main/webapp/resources/iam/css/iam.css b/iam-login-service/src/main/webapp/resources/iam/css/iam.css
index 2508b9319..b1008dee0 100644
--- a/iam-login-service/src/main/webapp/resources/iam/css/iam.css
+++ b/iam-login-service/src/main/webapp/resources/iam/css/iam.css
@@ -71,6 +71,12 @@
max-width: 250px;
}
+.verify-form {
+ padding-top: 1em;
+ margin: 0 auto;
+ max-width: 250px;
+}
+
#sign-aup-form {
padding-top: 2em;
margin: 0 auto;
@@ -106,6 +112,11 @@
max-width: 400px;
}
+#verify-error {
+ margin: 0 auto;
+ max-width: 400px;
+}
+
#login-external-authn {
margin: 0 auto;
padding-top: 2em;
@@ -125,6 +136,17 @@
max-width: 250px;
}
+#verify-registration-form {
+ padding-top: 2em;
+ margin: 0 auto;
+ text-align: justify;
+ max-width: 400px;
+}
+
+#verify-registration-form p {
+ font-size: larger;
+}
+
#register-confirm-message {
padding-top: 2em;
margin: 0 auto;
@@ -136,10 +158,19 @@
font-size: larger;
}
+#register-confirm-message p.error {
+ text-align: left;
+ font-weight: bold;
+}
+
#register-confirm-back-btn {
margin-top: 2em;
}
+#verify-confirm {
+ margin-top: 2em
+}
+
.reset-password-form {
margin: 0 auto;
max-width: 400px;
@@ -320,10 +351,20 @@ body.skin-blue {
color: inherit;
}
+.btn-verify {
+ background-color: white;
+ border-color: #ddd;
+ color: inherit;
+}
+
.btn-login:hover {
background-color: white;
}
+.btn-verify:hover {
+ background-color: white;
+}
+
.login-image-size-SMALL {
margin-left: 5px;
height: 22px;
@@ -348,6 +389,11 @@ body.skin-blue {
text-align: center;
}
+.verify-preamble {
+ margin-bottom: 1em;
+ text-align: center;
+}
+
.registration-preamble {
margin-bottom: 1em;
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/RegistrationUtils.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/RegistrationUtils.java
index 30a45ee2d..874cfe52e 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/RegistrationUtils.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/RegistrationUtils.java
@@ -18,7 +18,6 @@
import static io.restassured.RestAssured.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import org.hamcrest.Matchers;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@@ -50,33 +49,17 @@ public RegistrationRequestDto createRegistrationRequest(String username) throws
request.setUsername(username);
request.setNotes("Some short notes...");
- String responseJson =
- mvc
+ String responseJson = mvc
.perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
.andExpect(MockMvcResultMatchers.status().isOk())
- .andReturn().getResponse().getContentAsString();
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
request = mapper.readValue(responseJson, RegistrationRequestDto.class);
-
- return request;
- }
-
- public void confirmRegistrationRequest(String confirmationKey, int port) {
- // @formatter:off
- given()
- .port(port)
- .pathParam("token", confirmationKey)
- .when()
- .get("/registration/confirm/{token}")
- .then()
- .log()
- .body(true)
- .statusCode(HttpStatus.OK.value())
- .body("status", Matchers.equalTo(IamRegistrationRequestStatus.CONFIRMED.name()))
- ;
- // @formatter:on
+ return request;
}
public static RegistrationRequestDto approveRequest(String uuid, int port) {
@@ -109,7 +92,7 @@ private static RegistrationRequestDto requestDecision(String uuid,
TestUtils.getAccessToken("registration-client", "secret", "registration:write");
// @formatter:off
- RegistrationRequestDto req =
+ return
given()
.port(port)
.auth()
@@ -126,7 +109,6 @@ private static RegistrationRequestDto requestDecision(String uuid,
.extract().as(RegistrationRequestDto.class);
// @formatter:on
- return req;
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java
index 3abf150c0..df99ac521 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/TestSupport.java
@@ -43,7 +43,7 @@ public class TestSupport {
public static final String TEST_001_GROUP_UUID = "c617d586-54e6-411d-8e38-649677980001";
public static final String TEST_002_GROUP_UUID = "c617d586-54e6-411d-8e38-649677980002";
-
+
public static final String ADMIN_USER = "admin";
public static final String ADMIN_USER_UUID = "73f16d93-2441-4a50-88ff-85360d78c6b5";
@@ -81,6 +81,8 @@ public class TestSupport {
public static final ResultMatcher INVALID_NAME_ERROR_MESSAGE =
jsonPath("$.error", containsString("invalid name (does not match"));
+ public static final ResultMatcher INVALID_VALUE_ERROR_MESSAGE = jsonPath("$.error",
+ containsString("Invalid label: The string must not contain any new line or carriage return"));
public static final ResultMatcher NAME_TOO_LONG_ERROR_MESSAGE =
jsonPath("$.error", containsString("invalid name length"));
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/attributes/AccountAttributesTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/attributes/AccountAttributesTests.java
index a6a290102..c87d518a7 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/attributes/AccountAttributesTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/attributes/AccountAttributesTests.java
@@ -17,6 +17,7 @@
import static java.lang.String.format;
import static java.util.Arrays.asList;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.hasItem;
@@ -67,6 +68,7 @@ public class AccountAttributesTests {
public static final ResultMatcher UNAUTHORIZED = status().isUnauthorized();
public static final ResultMatcher FORBIDDEN = status().isForbidden();
public static final ResultMatcher NOT_FOUND = status().isNotFound();
+ public static final ResultMatcher BAD_REQUEST = status().isBadRequest();
public static final String TEST_USER = "test";
public static final String TEST_100_USER = "test_100";
@@ -145,7 +147,7 @@ public void aUserCanListHisAttributes() throws Exception {
mvc.perform(get(ACCOUNT_ATTR_URL_TEMPLATE, testAccount.getUuid())).andExpect(OK);
}
-
+
@Test
@WithMockUser(username = "test", roles = "USER")
public void managingAttributesRequiresPrivilegedUser() throws Exception {
@@ -390,4 +392,58 @@ public void multiAttributeSetTest() throws Exception {
attrs.forEach(a -> assertThat(results, hasItem(a)));
}
}
+
+ @Test
+ @WithMockUser(username = "admin", roles = "ADMIN")
+ public void attributeValidationTests() throws Exception {
+
+ AttributeDTO noNameAttribute = AttributeDTO.newInstance(null, ATTR_VALUE);
+
+ mvc
+ .perform(put(ACCOUNT_ATTR_URL_TEMPLATE, noNameAttribute).contentType(APPLICATION_JSON)
+ .content(mapper.writeValueAsString(noNameAttribute)))
+ .andExpect(BAD_REQUEST)
+ .andExpect(jsonPath("$.error", containsString("must not be blank")));
+
+ final String SOME_INVALID_NAMES[] =
+ {"-pippo", "/ciccio/paglia", ".starts-with-dot", "carriage\nreturn", "another\rreturn"};
+
+ for (String name : SOME_INVALID_NAMES) {
+ AttributeDTO invalidAttribute = AttributeDTO.newInstance(name, ATTR_VALUE);
+ mvc
+ .perform(put(ACCOUNT_ATTR_URL_TEMPLATE, invalidAttribute).contentType(APPLICATION_JSON)
+ .content(mapper.writeValueAsString(invalidAttribute)))
+ .andExpect(BAD_REQUEST)
+ .andExpect(jsonPath("$.error", containsString("invalid name (does not match with regexp")));
+ }
+
+ final String SOME_INVALID_VALES[] = {"carriage\nreturn", "another\rreturn"};
+
+ for (String value : SOME_INVALID_VALES) {
+ AttributeDTO invalidAttribute = AttributeDTO.newInstance(ATTR_NAME, value);
+ mvc
+ .perform(put(ACCOUNT_ATTR_URL_TEMPLATE, invalidAttribute).contentType(APPLICATION_JSON)
+ .content(mapper.writeValueAsString(invalidAttribute)))
+ .andExpect(BAD_REQUEST)
+ .andExpect(jsonPath("$.error",
+ containsString("The string must not contain any new line or carriage return")));
+ }
+
+ AttributeDTO longNameAttribute = AttributeDTO.newInstance(randomAlphabetic(65), ATTR_VALUE);
+
+ mvc
+ .perform(put(ACCOUNT_ATTR_URL_TEMPLATE, longNameAttribute).contentType(APPLICATION_JSON)
+ .content(mapper.writeValueAsString(longNameAttribute)))
+ .andExpect(BAD_REQUEST)
+ .andExpect(jsonPath("$.error", containsString("name cannot be longer than 64 chars")));
+
+
+ AttributeDTO longValueAttribute = AttributeDTO.newInstance(ATTR_NAME, randomAlphabetic(257));
+
+ mvc
+ .perform(put(ACCOUNT_ATTR_URL_TEMPLATE, longValueAttribute).contentType(APPLICATION_JSON)
+ .content(mapper.writeValueAsString(longValueAttribute)))
+ .andExpect(BAD_REQUEST)
+ .andExpect(jsonPath("$.error", containsString("value cannot be longer than 256 chars")));
+ }
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/labels/AccountLabelsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/labels/AccountLabelsTests.java
index b3d790b3d..b3b2ba390 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/labels/AccountLabelsTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/labels/AccountLabelsTests.java
@@ -330,7 +330,7 @@ public void multipleLabelsHandledCorrectly() throws Exception {
public void labelValidationTests() throws Exception {
final String[] SOME_INVALID_PREFIXES = {"aword", "-starts-with-dash.com", "ends-with-dash-.com",
- "contains_underscore.org", "contains/slashes.org"};
+ "contains_underscore.org", "contains/slashes.org", "carriage\nreturn", "another\rreturn"};
for (String p : SOME_INVALID_PREFIXES) {
LabelDTO l = LabelDTO.builder().prefix(p).value(LABEL_VALUE).name(LABEL_NAME).build();
@@ -349,7 +349,8 @@ public void labelValidationTests() throws Exception {
.andExpect(BAD_REQUEST)
.andExpect(NAME_REQUIRED_ERROR_MESSAGE);
- final String SOME_INVALID_NAMES[] = {"-pippo", "/ciccio/paglia", ".starts-with-dot"};
+ final String SOME_INVALID_NAMES[] =
+ {"-pippo", "/ciccio/paglia", ".starts-with-dot", "carriage\nreturn", "another\rreturn"};
for (String in : SOME_INVALID_NAMES) {
LabelDTO invalidNameLabel = LabelDTO.builder().prefix(LABEL_PREFIX).name(in).build();
@@ -360,6 +361,17 @@ public void labelValidationTests() throws Exception {
.andExpect(INVALID_NAME_ERROR_MESSAGE);
}
+ final String SOME_INVALID_VALUES[] = {"carriage\nreturn", "another\rreturn"};
+
+ for (String v : SOME_INVALID_VALUES) {
+ LabelDTO invalidNameLabel = LabelDTO.builder().prefix(LABEL_PREFIX).name(LABEL_NAME).value(v).build();
+ mvc
+ .perform(put(RESOURCE, TEST_001_GROUP_UUID).contentType(APPLICATION_JSON)
+ .content(mapper.writeValueAsString(invalidNameLabel)))
+ .andExpect(BAD_REQUEST)
+ .andExpect(INVALID_VALUE_ERROR_MESSAGE);
+ }
+
LabelDTO longNameLabel =
LabelDTO.builder().prefix(LABEL_PREFIX).name(randomAlphabetic(65)).build();
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/MultiFactorSettingsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/MultiFactorSettingsTests.java
new file mode 100644
index 000000000..26509dc35
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/MultiFactorSettingsTests.java
@@ -0,0 +1,125 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.api.account.multi_factor_authentication;
+
+import static it.infn.mw.iam.test.TestUtils.passwordTokenGetter;
+import static org.hamcrest.Matchers.equalTo;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import io.restassured.RestAssured;
+import io.restassured.response.ValidatableResponse;
+import it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsController;
+import it.infn.mw.iam.api.scim.model.ScimEmail;
+import it.infn.mw.iam.api.scim.model.ScimName;
+import it.infn.mw.iam.api.scim.model.ScimUser;
+import it.infn.mw.iam.api.scim.provisioning.ScimUserProvisioning;
+import it.infn.mw.iam.test.TestUtils;
+import it.infn.mw.iam.test.util.annotation.IamRandomPortIntegrationTest;
+
+@RunWith(SpringRunner.class)
+@IamRandomPortIntegrationTest
+public class MultiFactorSettingsTests {
+
+ @Value("${local.server.port}")
+ private Integer iamPort;
+
+ private ScimUser testUser;
+
+ private final String USER_USERNAME = "test_user";
+ private final String USER_PASSWORD = "password";
+ private final ScimName USER_NAME =
+ ScimName.builder().givenName("TESTER").familyName("USER").build();
+ private final ScimEmail USER_EMAIL = ScimEmail.builder().email("test_user@test.org").build();
+
+ @Autowired
+ private ScimUserProvisioning userService;
+
+ @BeforeClass
+ public static void init() {
+ TestUtils.initRestAssured();
+ }
+
+ @Before
+ public void setup() {
+ testUser = userService.create(ScimUser.builder()
+ .active(true)
+ .addEmail(USER_EMAIL)
+ .name(USER_NAME)
+ .displayName(USER_USERNAME)
+ .userName(USER_USERNAME)
+ .password(USER_PASSWORD)
+ .build());
+ }
+
+ @After
+ public void tearDown() {
+ userService.delete(testUser.getId());
+ }
+
+ private ValidatableResponse doGet(String accessToken) {
+ return RestAssured.given()
+ .port(iamPort)
+ .auth()
+ .preemptive()
+ .oauth2(accessToken)
+ .log()
+ .all(true)
+ .when()
+ .get(MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_URL)
+ .then()
+ .log()
+ .all(true);
+ }
+
+ private ValidatableResponse doGet() {
+ return RestAssured.given()
+ .port(iamPort)
+ .log()
+ .all(true)
+ .when()
+ .get(MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_URL)
+ .then()
+ .log()
+ .all(true);
+ }
+
+ @Test
+ public void testGetSettings() {
+ String accessToken = passwordTokenGetter().port(iamPort)
+ .username(testUser.getUserName())
+ .password(USER_PASSWORD)
+ .getAccessToken();
+
+ doGet(accessToken).statusCode(HttpStatus.OK.value());
+ }
+
+ @Test
+ public void testGetSettingsFullAuthenticationRequired() {
+ doGet().statusCode(HttpStatus.UNAUTHORIZED.value())
+ .body("error", equalTo("unauthorized"))
+ .body("error_description",
+ equalTo("Full authentication is required to access this resource"));
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticationAppSettingsTotpTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticationAppSettingsTotpTests.java
new file mode 100644
index 000000000..bb7b18094
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticationAppSettingsTotpTests.java
@@ -0,0 +1,141 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.api.account.multi_factor_authentication.authenticator_app;
+
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ADD_SECRET_URL;
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ENABLE_URL;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import dev.samstevens.totp.exceptions.QrGenerationException;
+import dev.samstevens.totp.qr.QrData;
+import dev.samstevens.totp.qr.QrGenerator;
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.config.mfa.IamTotpMfaProperties;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.test.TestUtils;
+import it.infn.mw.iam.test.multi_factor_authentication.MultiFactorTestSupport;
+import it.infn.mw.iam.test.util.WithMockOAuthUser;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+
+@RunWith(SpringRunner.class)
+@IamMockMvcIntegrationTest
+public class AuthenticationAppSettingsTotpTests extends MultiFactorTestSupport {
+
+ private MockMvc mvc;
+
+ @Autowired
+ private WebApplicationContext context;
+
+ @MockBean
+ private IamAccountRepository accountRepository;
+
+ @MockBean
+ private IamTotpMfaService totpMfaService;
+
+ @MockBean
+ private IamTotpMfaProperties iamTotpMfaProperties;
+
+ @MockBean
+ private QrGenerator qrGenerator;
+
+
+ @BeforeClass
+ public static void init() {
+ TestUtils.initRestAssured();
+ }
+
+ @Before
+ public void setup() {
+ when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT));
+ when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT));
+ when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(KEY_TO_ENCRYPT_DECRYPT);
+
+ mvc =
+ MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build();
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testAddSecretThrowsQrGenerationException() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(account));
+
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa);
+
+ when(qrGenerator.generate(any(QrData.class))).thenThrow(
+ new QrGenerationException("Simulated QR generation failure", new RuntimeException()));
+
+ mvc.perform(put(ADD_SECRET_URL))
+ .andExpect(status().isBadRequest())
+ .andExpect(content().string(containsString("Could not generate QR code")));
+
+ verify(accountRepository, times(2)).findByUsername(TEST_USERNAME);
+ verify(totpMfaService, times(1)).addTotpMfaSecret(account);
+ verify(qrGenerator, times(1)).generate(any(QrData.class));
+ }
+
+ @Test
+ @WithMockOAuthUser(user = TEST_USERNAME, authorities = "ROLE_USER")
+ public void testEnableAuthenticatorAppViaOauthAuthn() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setActive(true);
+ totpMfa.setAccount(account);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp)).thenReturn(true);
+ when(totpMfaService.enableTotpMfa(account)).thenReturn(totpMfa);
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isOk());
+
+ verify(accountRepository, times(2)).findByUsername(TEST_USERNAME);
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, times(1)).enableTotpMfa(account);
+ }
+}
\ No newline at end of file
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java
new file mode 100644
index 000000000..040a59ecf
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java
@@ -0,0 +1,421 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.api.account.multi_factor_authentication.authenticator_app;
+
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ADD_SECRET_URL;
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.DISABLE_URL;
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.ENABLE_URL;
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.MFA_SECRET_NOT_FOUND_MESSAGE;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.util.NestedServletException;
+
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.config.mfa.IamTotpMfaProperties;
+import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException;
+import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
+import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.test.TestUtils;
+import it.infn.mw.iam.test.multi_factor_authentication.MultiFactorTestSupport;
+import it.infn.mw.iam.test.util.WithAnonymousUser;
+import it.infn.mw.iam.test.util.WithMockMfaUser;
+import it.infn.mw.iam.test.util.WithMockPreAuthenticatedUser;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+
+@RunWith(SpringRunner.class)
+@IamMockMvcIntegrationTest
+public class AuthenticatorAppSettingsControllerTests extends MultiFactorTestSupport {
+
+ private MockMvc mvc;
+
+ @Autowired
+ private WebApplicationContext context;
+
+ @MockBean
+ private IamAccountRepository accountRepository;
+
+ @MockBean
+ private IamTotpMfaService totpMfaService;
+
+ @MockBean
+ private IamTotpMfaProperties iamTotpMfaProperties;
+
+ @BeforeClass
+ public static void init() {
+ TestUtils.initRestAssured();
+ }
+
+ @Before
+ public void setup() {
+ when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT));
+ when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT));
+ when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(KEY_TO_ENCRYPT_DECRYPT);
+
+ mvc =
+ MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build();
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testAddSecret() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setActive(false);
+ totpMfa.setAccount(null);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa);
+
+ mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isOk());
+
+ verify(accountRepository, times(2)).findByUsername(TEST_USERNAME);
+ verify(totpMfaService, times(1)).addTotpMfaSecret(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testAddSecretThrowsMfaSecretAlreadyBoundException() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setActive(false);
+ totpMfa.setAccount(null);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ when(totpMfaService.addTotpMfaSecret(account)).thenThrow(new MfaSecretAlreadyBoundException(
+ "A multi-factor secret is already assigned to this account"));
+
+ mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isConflict());
+
+ verify(accountRepository, times(2)).findByUsername(TEST_USERNAME);
+ verify(totpMfaService, times(1)).addTotpMfaSecret(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testAddSecret_withEmptyPassword() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setActive(false);
+ totpMfa.setAccount(null);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+
+ when(totpMfaService.addTotpMfaSecret(account)).thenReturn(totpMfa);
+ when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn("");
+
+ NestedServletException thrownException = assertThrows(NestedServletException.class, () -> {
+ mvc.perform(put(ADD_SECRET_URL));
+ });
+
+ assertTrue(
+ thrownException.getCause().getMessage().startsWith("Please ensure that you provide"));
+ }
+
+ @Test
+ @WithAnonymousUser
+ public void testAddSecretNoAuthenticationIsUnauthorized() throws Exception {
+ mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockPreAuthenticatedUser
+ public void testAddSecretPreAuthenticationIsUnauthorized() throws Exception {
+ mvc.perform(put(ADD_SECRET_URL)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorApp() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setActive(true);
+ totpMfa.setAccount(account);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp)).thenReturn(true);
+ when(totpMfaService.enableTotpMfa(account)).thenReturn(totpMfa);
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isOk());
+
+ verify(accountRepository, times(2)).findByUsername(TEST_USERNAME);
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, times(1)).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppThrowsTotpMfaAlreadyEnabledException() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp)).thenReturn(true);
+ when(totpMfaService.enableTotpMfa(account))
+ .thenThrow(new TotpMfaAlreadyEnabledException("TOTP MFA is already enabled on this account"));
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isConflict());
+
+ verify(accountRepository, times(2)).findByUsername(TEST_USERNAME);
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, times(1)).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppIncorrectCode() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp)).thenReturn(false);
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppButTotpVerificationFails() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp))
+ .thenThrow(new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE));
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppInvalidCharactersInCode() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "abcdef";
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppCodeTooShort() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "12345";
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppCodeTooLong() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "1234567";
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppNullCode() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = null;
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockUser(username = TEST_USERNAME)
+ public void testEnableAuthenticatorAppEmptyCode() throws Exception {
+ IamAccount account = cloneAccount(TEST_ACCOUNT);
+ String totp = "";
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).enableTotpMfa(account);
+ }
+
+ @Test
+ @WithAnonymousUser
+ public void testEnableAuthenticatorAppNoAuthenticationIsUnauthorized() throws Exception {
+ String totp = "123456";
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockPreAuthenticatedUser
+ public void testEnableAuthenticatorAppPreAuthenticationIsUnauthorized() throws Exception {
+ String totp = "654321";
+
+ mvc.perform(post(ENABLE_URL).param("code", totp)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorApp() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp)).thenReturn(true);
+ when(totpMfaService.disableTotpMfa(account)).thenReturn(totpMfa);
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().isOk());
+
+ verify(accountRepository, times(2)).findByUsername(TOTP_USERNAME);
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, times(1)).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppIncorrectCode() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp)).thenReturn(false);
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppButTotpVerificationFails() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = "123456";
+
+ when(totpMfaService.verifyTotp(account, totp))
+ .thenThrow(new MfaSecretNotFoundException(MFA_SECRET_NOT_FOUND_MESSAGE));
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, times(1)).verifyTotp(account, totp);
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppInvalidCharactersInCode() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = "123456";
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppCodeTooShort() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = "12345";
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppCodeTooLong() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = "1234567";
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppNullCode() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = null;
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithMockMfaUser
+ public void testDisableAuthenticatorAppEmptyCode() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ String totp = "";
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().is4xxClientError());
+
+ verify(totpMfaService, never()).disableTotpMfa(account);
+ }
+
+ @Test
+ @WithAnonymousUser
+ public void testDisableAuthenticatorAppNoAuthenticationIsUnauthorized() throws Exception {
+ String totp = "123456";
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockPreAuthenticatedUser
+ public void testDisableAuthenticatorAppPreAuthenticationIsUnauthorized() throws Exception {
+ String totp = "654321";
+
+ mvc.perform(post(DISABLE_URL).param("code", totp)).andExpect(status().isUnauthorized());
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/password/PasswordEncodingTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/password/PasswordEncodingTests.java
index 257a58ea9..046aaf92a 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/password/PasswordEncodingTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/account/password/PasswordEncodingTests.java
@@ -16,10 +16,11 @@
package it.infn.mw.iam.test.api.account.password;
import static it.infn.mw.iam.test.util.AuthenticationUtils.adminAuthentication;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.After;
@@ -97,8 +98,11 @@ public void testNoValidResetToken() throws Exception {
.getContentAsString();
String confirmationKey = "NoValidToken";
- mvc.perform(get("/registration/confirm/{token}", confirmationKey).contentType(APPLICATION_JSON))
- .andExpect(status().isNotFound());
+ mvc
+ .perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationFailure"));
}
@@ -125,8 +129,10 @@ public void testPasswordEncoded() throws Exception {
request = mapper.readValue(rs, RegistrationRequestDto.class);
String confirmationKey = tokenGenerator.getLastToken();
- mvc.perform(get("/registration/confirm/{token}", confirmationKey).contentType(APPLICATION_JSON))
- .andExpect(status().isOk());
+ mvc.perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
mvc.perform(post("/registration/approve/{uuid}", request.getUuid())
.with(authentication(adminAuthentication()))
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/aup/AupIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/aup/AupIntegrationTests.java
index a4736dd14..281c8c230 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/aup/AupIntegrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/aup/AupIntegrationTests.java
@@ -25,6 +25,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Date;
@@ -64,9 +65,9 @@ public class AupIntegrationTests extends AupTestSupport {
private final String INVALID_AUP_URL =
"https://iam.local.io/\"";
- private final static String DEFAULT_AUP_TEXT = null;
- private final static String DEFAULT_AUP_URL = "http://updated-aup-text.org/";
- private final static String DEFAULT_AUP_DESC = "desc";
+ private static final String DEFAULT_AUP_TEXT = null;
+ private static final String DEFAULT_AUP_URL = "http://updated-aup-text.org/";
+ private static final String DEFAULT_AUP_DESC = "desc";
@Autowired
@@ -765,4 +766,29 @@ public void aupUpdateWorks() throws Exception {
assertThat(updatedAup.getSignatureValidityInDays(), equalTo(31L));
}
+ @Test
+ public void anonymousAupSignLinkRedirectsToLoginPage() throws Exception {
+ mvc.perform(get("/iam/aup/sign"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("http://localhost/login"));
+ }
+
+ @Test
+ @WithMockUser(username = "actuator-user", roles = {"ACTUATOR"})
+ public void aupSignLinkForbiddenToActuatorUser() throws Exception {
+ mvc.perform(get("/iam/aup/sign")).andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(username = "test", roles = {"USER"})
+ public void aupSignLinkAllowedToUsers() throws Exception {
+ mvc.perform(get("/iam/aup/sign")).andExpect(status().isOk());
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = {"ADMIN", "USER"})
+ public void aupSignLinkAllowedToAdmins() throws Exception {
+ mvc.perform(get("/iam/aup/sign")).andExpect(status().isOk());
+ }
+
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java
index 14ee9764d..c95bdcbec 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/requests/GroupRequestsGetDetailsTests.java
@@ -53,7 +53,7 @@ public class GroupRequestsGetDetailsTests extends GroupRequestsTestUtils {
public void getGroupRequestDetailsAsAdmin() throws Exception {
GroupRequestDto request = savePendingGroupRequest(TEST_100_USERNAME, TEST_001_GROUPNAME);
-
+
// @formatter:off
mvc.perform(get(GET_DETAILS_URL, request.getUuid()))
.andExpect(status().isOk())
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/tokens/TestTokensUtils.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/tokens/TestTokensUtils.java
index b9f2064d8..92ae92667 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/api/tokens/TestTokensUtils.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/api/tokens/TestTokensUtils.java
@@ -19,10 +19,10 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
import java.util.Calendar;
import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
import org.mitre.oauth2.model.ClientDetailsEntity;
import org.mitre.oauth2.model.OAuth2AccessTokenEntity;
@@ -40,9 +40,7 @@
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
-import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import it.infn.mw.iam.api.common.ListResponseDTO;
@@ -94,15 +92,17 @@ private OAuth2Authentication oauth2Authentication(ClientDetailsEntity client, St
String[] scopes) {
Authentication userAuth = null;
+ Map requestParameters = new HashMap();
+ requestParameters.put("grant_type", "authorization_code");
if (username != null) {
userAuth = new UsernamePasswordAuthenticationToken(username, "");
}
MockOAuth2Request req = new MockOAuth2Request(client.getClientId(), scopes);
- OAuth2Authentication auth = new OAuth2Authentication(req, userAuth);
+ req.setRequestParameters(requestParameters);
+ return new OAuth2Authentication(req, userAuth);
- return auth;
}
public ClientDetailsEntity loadTestClient(String clientId) {
@@ -111,14 +111,12 @@ public ClientDetailsEntity loadTestClient(String clientId) {
public IamAccount loadTestUser(String userId) {
return accountRepository.findByUsername(userId)
- .orElseThrow(() -> new IamAccountException("User not found"));
+ .orElseThrow(() -> new IamAccountException("User not found"));
}
public OAuth2AccessTokenEntity buildAccessToken(ClientDetailsEntity client, String username,
String[] scopes) {
- OAuth2AccessTokenEntity token =
- tokenService.createAccessToken(oauth2Authentication(client, username, scopes));
- return token;
+ return tokenService.createAccessToken(oauth2Authentication(client, username, scopes));
}
public OAuth2AccessTokenEntity buildExpiredAccessToken(ClientDetailsEntity client,
@@ -149,9 +147,7 @@ public OAuth2AccessTokenEntity buildAccessTokenWithExpiredRefreshToken(ClientDet
}
public OAuth2AccessTokenEntity buildAccessToken(ClientDetailsEntity client, String[] scopes) {
- OAuth2AccessTokenEntity token =
- tokenService.createAccessToken(oauth2Authentication(client, null, scopes));
- return token;
+ return tokenService.createAccessToken(oauth2Authentication(client, null, scopes));
}
public void clearAllTokens() {
@@ -164,15 +160,13 @@ public Authentication anonymousAuthenticationToken() {
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
}
- protected ListResponseDTO getAccessTokenList() throws JsonParseException,
- JsonMappingException, UnsupportedEncodingException, IOException, Exception {
+ protected ListResponseDTO getAccessTokenList() throws Exception {
return getAccessTokenList(new LinkedMultiValueMap());
}
protected ListResponseDTO getAccessTokenList(MultiValueMap params)
- throws JsonParseException, JsonMappingException, UnsupportedEncodingException, IOException,
- Exception {
+ throws Exception {
/* @formatter:off */
return mapper.readValue(
@@ -186,15 +180,13 @@ protected ListResponseDTO getAccessTokenList(MultiValueMap getRefreshTokenList() throws JsonParseException,
- JsonMappingException, UnsupportedEncodingException, IOException, Exception {
+ protected ListResponseDTO getRefreshTokenList() throws Exception {
return getRefreshTokenList(new LinkedMultiValueMap());
}
protected ListResponseDTO getRefreshTokenList(MultiValueMap params)
- throws JsonParseException, JsonMappingException, UnsupportedEncodingException, IOException,
- Exception {
+ throws Exception {
/* @formatter:off */
return mapper.readValue(
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509AuthenticationIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509AuthenticationIntegrationTests.java
index a4cf1b9cd..973ab3dda 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509AuthenticationIntegrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509AuthenticationIntegrationTests.java
@@ -21,6 +21,7 @@
import static it.infn.mw.iam.authn.x509.IamX509PreauthenticationProcessingFilter.X509_CAN_LOGIN_KEY;
import static it.infn.mw.iam.authn.x509.IamX509PreauthenticationProcessingFilter.X509_CREDENTIAL_SESSION_KEY;
import static java.lang.Boolean.TRUE;
+import static java.util.Collections.singletonList;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
@@ -44,6 +45,7 @@
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
+import java.util.Optional;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -61,6 +63,7 @@
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.model.IamX509Certificate;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamX509CertificateRepository;
import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
import junit.framework.AssertionFailedError;
@@ -73,6 +76,9 @@ public class X509AuthenticationIntegrationTests extends X509TestSupport {
@Autowired
private IamAccountRepository iamAccountRepo;
+ @Autowired
+ private IamX509CertificateRepository iamX509CertificateRepo;
+
@Autowired
private MockMvc mvc;
@@ -173,6 +179,7 @@ public void testx509AccountLinking() throws Exception {
(IamX509AuthenticationCredential) session.getAttribute(X509_CREDENTIAL_SESSION_KEY);
assertThat(credential.getSubject(), equalTo(TEST_0_SUBJECT));
+ assertThat(credential.getIssuer(), equalTo(TEST_0_ISSUER));
String confirmationMessage =
String.format("Certificate '%s' linked succesfully", credential.getSubject());
@@ -183,6 +190,14 @@ public void testx509AccountLinking() throws Exception {
.andExpect(
flash().attribute(ACCOUNT_LINKING_DASHBOARD_MESSAGE_KEY, equalTo(confirmationMessage)));
+ Optional linkedUser =
+ iamX509CertificateRepo.findBySubjectDn(TEST_0_SUBJECT).stream().findFirst();
+ assertThat(linkedUser.isPresent(), is(true));
+ assertThat(linkedUser.get().getUsername(), is("test"));
+
+ Optional test0Cert = iamX509CertificateRepo.findBySubjectDnAndIssuerDn(TEST_0_SUBJECT, TEST_0_ISSUER);
+ assertThat(test0Cert.isPresent(), is(true));
+
IamAccount linkedAccount = iamAccountRepo.findByCertificateSubject(TEST_0_SUBJECT)
.orElseThrow(() -> new AssertionFailedError("Expected user linked to certificate not found"));
@@ -214,16 +229,71 @@ public void testx509AccountLinking() throws Exception {
String.format("Certificate '%s' linked succesfully", credential1.getSubject());
mvc.perform(post("/iam/account-linking/X509").session(session1).with(csrf().asHeader()))
- .andExpect(status().is3xxRedirection())
- .andExpect(redirectedUrl("/dashboard"))
- .andExpect(
- flash().attribute(ACCOUNT_LINKING_DASHBOARD_MESSAGE_KEY, equalTo(confirmationMsg)));
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dashboard"))
+ .andExpect(
+ flash().attribute(ACCOUNT_LINKING_DASHBOARD_MESSAGE_KEY, equalTo(confirmationMsg)));
- linkedAccount = iamAccountRepo.findByCertificateSubject(TEST_0_SUBJECT)
- .orElseThrow(() -> new AssertionFailedError("Expected user linked to certificate not found"));
+ Optional testCert1 = iamX509CertificateRepo.findBySubjectDnAndIssuerDn(TEST_0_SUBJECT, TEST_0_ISSUER);
+ assertThat(testCert1.isPresent(), is(true));
+ assertThat(testCert1.get().getAccount().getUsername(), is("test"));
+
+ Optional testCert2 = iamX509CertificateRepo.findBySubjectDnAndIssuerDn(TEST_0_SUBJECT, TEST_NEW_ISSUER);
+ assertThat(testCert2.isPresent(), is(true));
+ assertThat(testCert2.get().getAccount().getUsername(), is("test"));
+
+ // Try to link cert to another user
+ MockHttpSession session2 = loginAsTest100UserWithTest0Cert(mvc);
+ IamX509AuthenticationCredential credential2 =
+ (IamX509AuthenticationCredential) session2.getAttribute(X509_CREDENTIAL_SESSION_KEY);
- assertThat(linkedAccount.getX509Certificates().size(), is(2));
+ assertThat(credential2.getSubject(), equalTo(TEST_0_SUBJECT));
+ assertThat(credential2.getIssuer(), equalTo(TEST_0_ISSUER));
+ String expectedErrorMessage =
+ String.format("X.509 credential with subject '%s' is already linked to another user",
+ credential2.getSubject());
+
+ mvc.perform(post("/iam/account-linking/X509").session(session2).with(csrf().asHeader()))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(
+ flash().attribute(ACCOUNT_LINKING_DASHBOARD_ERROR_KEY, equalTo(expectedErrorMessage)));
+ }
+
+ @Test
+ public void testUpdateCertWithSameIssuerAndSubjectButDifferentPem() throws Exception {
+
+ IamAccount account = iamAccountRepo.findByUsername(TEST_USERNAME)
+ .orElseThrow(() -> new AssertionFailedError("Account not found"));
+ account.linkX509Certificates(singletonList(OLD_TEST_0_IAM_X509_CERT));
+
+ String oldPemCert = OLD_TEST_0_IAM_X509_CERT.getCertificate();
+
+ MockHttpSession session = loginAsTestUserWithTest0Cert(mvc);
+ IamX509AuthenticationCredential credential =
+ (IamX509AuthenticationCredential) session.getAttribute(X509_CREDENTIAL_SESSION_KEY);
+
+ String confirmationMessage =
+ String.format("Certificate '%s' linked succesfully", credential.getSubject());
+
+ mvc.perform(post("/iam/account-linking/X509").session(session).with(csrf().asHeader()))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dashboard"))
+ .andExpect(
+ flash().attribute(ACCOUNT_LINKING_DASHBOARD_MESSAGE_KEY, equalTo(confirmationMessage)));
+
+ Optional testCert =
+ iamX509CertificateRepo.findBySubjectDnAndIssuerDn(TEST_0_SUBJECT, TEST_0_ISSUER);
+ assertThat(testCert.isPresent(), is(true));
+ assertThat(
+ account.getX509Certificates()
+ .stream()
+ .anyMatch(cert -> cert.getCertificate().equals(testCert.get().getCertificate())),
+ is(true));
+
+ assertThat(account.getX509Certificates()
+ .stream()
+ .anyMatch(cert -> cert.getCertificate().equals(oldPemCert)), is(false));
}
@Test
@@ -261,15 +331,15 @@ public void testx509AccountLinkingWithDifferentSubjectAndIssuer() throws Excepti
String.format("Certificate '%s' linked succesfully", credential1.getSubject());
mvc.perform(post("/iam/account-linking/X509").session(session1).with(csrf().asHeader()))
- .andExpect(status().is3xxRedirection())
- .andExpect(redirectedUrl("/dashboard"))
- .andExpect(
- flash().attribute(ACCOUNT_LINKING_DASHBOARD_MESSAGE_KEY, equalTo(confirmationMsg)));
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dashboard"))
+ .andExpect(
+ flash().attribute(ACCOUNT_LINKING_DASHBOARD_MESSAGE_KEY, equalTo(confirmationMsg)));
linkedAccount = iamAccountRepo.findByCertificateSubject(TEST_1_SUBJECT)
- .orElseThrow(() -> new AssertionFailedError("Expected user linked to certificate not found"));
+ .orElseThrow(() -> new AssertionFailedError("Expected user linked to certificate not found"));
- assertThat(linkedAccount.getX509Certificates().size(), is(2));
+ assertThat(linkedAccount.getX509Certificates().size(), is(2));
}
@Test
@@ -361,17 +431,20 @@ public void testx509AuthNFailsIfDisabledUser() throws Exception {
@Test
public void testHashAndEqualsMethods() {
- HashSet set1 = new HashSet(Arrays.asList(TEST_0_IAM_X509_CERT, TEST_1_IAM_X509_CERT));
- assertThat(set1.size(), is(2));
- assertNotEquals(TEST_0_IAM_X509_CERT.hashCode(), TEST_1_IAM_X509_CERT.hashCode());
- assertEquals(set1.hashCode(), TEST_0_IAM_X509_CERT.hashCode()+TEST_1_IAM_X509_CERT.hashCode());
- assertNotEquals(TEST_0_IAM_X509_CERT, TEST_1_IAM_X509_CERT);
-
- HashSet set2 = new HashSet(Arrays.asList(TEST_0_IAM_X509_CERT, TEST_2_IAM_X509_CERT));
- assertThat(set2.size(), is(1));
- assertEquals(TEST_0_IAM_X509_CERT.hashCode(), TEST_2_IAM_X509_CERT.hashCode());
- assertEquals(set2.hashCode(), TEST_0_IAM_X509_CERT.hashCode());
- assertEquals(TEST_0_IAM_X509_CERT, TEST_2_IAM_X509_CERT);
+ HashSet set1 =
+ new HashSet(Arrays.asList(TEST_0_IAM_X509_CERT, TEST_1_IAM_X509_CERT));
+ assertThat(set1.size(), is(2));
+ assertNotEquals(TEST_0_IAM_X509_CERT.hashCode(), TEST_1_IAM_X509_CERT.hashCode());
+ assertEquals(set1.hashCode(),
+ TEST_0_IAM_X509_CERT.hashCode() + TEST_1_IAM_X509_CERT.hashCode());
+ assertNotEquals(TEST_0_IAM_X509_CERT, TEST_1_IAM_X509_CERT);
+
+ HashSet set2 =
+ new HashSet(Arrays.asList(TEST_0_IAM_X509_CERT, TEST_2_IAM_X509_CERT));
+ assertThat(set2.size(), is(1));
+ assertEquals(TEST_0_IAM_X509_CERT.hashCode(), TEST_2_IAM_X509_CERT.hashCode());
+ assertEquals(set2.hashCode(), TEST_0_IAM_X509_CERT.hashCode());
+ assertEquals(TEST_0_IAM_X509_CERT, TEST_2_IAM_X509_CERT);
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509TestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509TestSupport.java
index c6cba6b2f..298e0f9db 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509TestSupport.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/ext_authn/x509/X509TestSupport.java
@@ -53,6 +53,8 @@
public class X509TestSupport {
public static final String TEST_0_CERT_PATH = "src/test/resources/x509/test0.cert.pem";
+ public static final String OLD_TEST_0_CERT_PATH = "src/test/resources/x509/oldtest0.cert.pem";
+ public static final String OLD_TEST_0_KEY_PATH = "src/test/resources/x509/oldtest0.key.pem";
public static final String TEST_0_KEY_PATH = "src/test/resources/x509/test0.key.pem";
public static final String TEST_0_SUBJECT = "CN=test0,O=IGI,C=IT";
@@ -67,7 +69,7 @@ public class X509TestSupport {
public static final String TEST_1_SERIAL = "10";
public static final String TEST_1_V_START = "Sep 26 15:39:36 2012 GMT";
public static final String TEST_1_V_END = "Sep 24 15:39:36 2022 GMT";
-
+
public static final String TEST_NEW_ISSUER = "CN=Test1 CA,O=IGI,C=IT";
public static final String RCAUTH_CA_CERT_PATH = "src/test/resources/x509/rcauth-mock-ca.p12";
@@ -76,14 +78,18 @@ public class X509TestSupport {
public static final String RCAUTH_CA_SUBJECT = "CN=RCAuth Mock CA,O=INDIGO-IAM,C=IT";
protected X509Certificate TEST_0_CERT;
- protected String TEST_0_CERT_STRING;
- protected String TEST_0_CERT_STRING_NGINX;
-
+ protected X509Certificate OLD_TEST_0_CERT;
protected X509Certificate TEST_1_CERT;
+
+ protected String TEST_0_CERT_STRING;
+ protected String OLD_TEST_0_CERT_STRING;
protected String TEST_1_CERT_STRING;
+
+ protected String TEST_0_CERT_STRING_NGINX;
protected String TEST_1_CERT_STRING_NGINX;
protected IamX509Certificate TEST_0_IAM_X509_CERT;
+ protected IamX509Certificate OLD_TEST_0_IAM_X509_CERT;
protected IamX509Certificate TEST_1_IAM_X509_CERT;
protected IamX509Certificate TEST_2_IAM_X509_CERT;
@@ -91,8 +97,10 @@ public class X509TestSupport {
protected String TEST_0_CERT_LABEL = "TEST 0 cert label";
protected String TEST_1_CERT_LABEL = "TEST 1 cert label";
+ protected String OLD_TEST_0_CERT_LABEL = "Old TEST 0 cert label";
protected String TEST_USERNAME = "test";
+ protected String TEST_100_USERNAME = "test_100";
protected String TEST_PASSWORD = "password";
protected X509Credential RCAUTH_CA_CRED;
@@ -111,6 +119,12 @@ protected X509TestSupport() {
new ByteArrayInputStream(TEST_1_CERT_STRING.getBytes(StandardCharsets.US_ASCII)),
Encoding.PEM);
+ OLD_TEST_0_CERT_STRING = new String(Files.readAllBytes(Paths.get(OLD_TEST_0_CERT_PATH)));
+
+ OLD_TEST_0_CERT = CertificateUtils.loadCertificate(
+ new ByteArrayInputStream(OLD_TEST_0_CERT_STRING.getBytes(StandardCharsets.US_ASCII)),
+ Encoding.PEM);
+
TEST_0_IAM_X509_CERT = new IamX509Certificate();
TEST_0_IAM_X509_CERT.setCertificate(TEST_0_CERT_STRING);
TEST_0_IAM_X509_CERT.setSubjectDn(TEST_0_SUBJECT);
@@ -118,6 +132,13 @@ protected X509TestSupport() {
TEST_0_IAM_X509_CERT.setLabel(TEST_0_CERT_LABEL);
TEST_0_IAM_X509_CERT.setPrimary(false);
+ OLD_TEST_0_IAM_X509_CERT = new IamX509Certificate();
+ OLD_TEST_0_IAM_X509_CERT.setCertificate(OLD_TEST_0_CERT_STRING);
+ OLD_TEST_0_IAM_X509_CERT.setSubjectDn(TEST_0_SUBJECT);
+ OLD_TEST_0_IAM_X509_CERT.setIssuerDn(TEST_0_ISSUER);
+ OLD_TEST_0_IAM_X509_CERT.setLabel(OLD_TEST_0_CERT_LABEL);
+ OLD_TEST_0_IAM_X509_CERT.setPrimary(false);
+
TEST_1_IAM_X509_CERT = new IamX509Certificate();
TEST_1_IAM_X509_CERT.setCertificate(TEST_1_CERT_STRING);
TEST_1_IAM_X509_CERT.setSubjectDn(TEST_1_SUBJECT);
@@ -183,6 +204,33 @@ protected MockHttpSession loginAsTestUserWithTest0Cert(MockMvc mvc) throws Excep
return session;
}
+ protected MockHttpSession loginAsTest100UserWithTest0Cert(MockMvc mvc) throws Exception {
+
+ MockHttpSession session =
+ (MockHttpSession) mvc.perform(get("/").headers(test0SSLHeadersVerificationSuccess()))
+ .andExpect(status().isFound())
+ .andExpect(redirectedUrl("http://localhost/login"))
+ .andExpect(MockMvcResultMatchers.request()
+ .sessionAttribute(X509_CREDENTIAL_SESSION_KEY, notNullValue()))
+ .andReturn()
+ .getRequest()
+ .getSession();
+
+ session = (MockHttpSession) mvc
+ .perform(post("/login").session(session)
+ .param("username", TEST_100_USERNAME)
+ .param("password", TEST_PASSWORD)
+ .param("submit", "Login"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("http://localhost/"))
+ .andExpect(authenticated().withUsername("test_100"))
+ .andReturn()
+ .getRequest()
+ .getSession();
+
+ return session;
+ }
+
protected MockHttpSession loginAsTestUserWithTest1Cert(MockMvc mvc) throws Exception {
MockHttpSession session =
@@ -434,11 +482,6 @@ protected void mockHttpRequestWithTest0SSLHeaders(HttpServletRequest request) {
.getHeader(DefaultX509AuthenticationCredentialExtractor.Headers.PROTOCOL.getHeader()))
.thenReturn("TLS");
- // Mockito
- // .when(request
- // .getHeader(DefaultX509AuthenticationCredentialExtractor.Headers.SERVER_NAME.getHeader()))
- // .thenReturn("serverName");
-
Mockito
.when(request
.getHeader(DefaultX509AuthenticationCredentialExtractor.Headers.VERIFY.getHeader()))
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java
index cb97f60e9..9d6588164 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleNoSuspensionGracePeriodTests.java
@@ -51,8 +51,7 @@
@IamMockMvcIntegrationTest
@SpringBootTest(
classes = {IamLoginService.class, CoreControllerTestSupport.class,
- AccountLifecycleNoSuspensionGracePeriodTests.TestConfig.class},
- webEnvironment = WebEnvironment.MOCK)
+ AccountLifecycleNoSuspensionGracePeriodTests.TestConfig.class}, webEnvironment = WebEnvironment.MOCK)
@TestPropertySource(
properties = {"lifecycle.account.expiredAccountPolicy.suspensionGracePeriodDays=0",
"lifecycle.account.expiredAccountPolicy.removalGracePeriodDays=30"})
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java
index bd00353a1..489537e99 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/AccountLifecycleTests.java
@@ -203,5 +203,4 @@ public void testNoAccountsRemoved() {
assertThat(accountBefore, is(accountAfter));
}
-
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleDisableUserTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleDisableUserTests.java
new file mode 100644
index 000000000..ea79c2aee
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleDisableUserTests.java
@@ -0,0 +1,259 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.lifecycle.cern;
+
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.NO_PARTICIPATION_MESSAGE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.NO_PERSON_FOUND_MESSAGE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_MESSAGE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_STATUS;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_TIMESTAMP;
+import static java.lang.String.format;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.when;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Optional;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import com.mercateo.test.clock.TestClock;
+
+import it.infn.mw.iam.IamLoginService;
+import it.infn.mw.iam.api.registration.cern.CernHrDBApiService;
+import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO;
+import it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler;
+import it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus;
+import it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler;
+import it.infn.mw.iam.core.lifecycle.cern.CernStatus;
+import it.infn.mw.iam.core.user.IamAccountService;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamLabel;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.test.api.TestSupport;
+import it.infn.mw.iam.test.core.CoreControllerTestSupport;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+
+
+@RunWith(SpringRunner.class)
+@IamMockMvcIntegrationTest
+@SpringBootTest(classes = {IamLoginService.class, CoreControllerTestSupport.class,
+ CernAccountLifecycleDisableUserTests.TestConfig.class})
+@TestPropertySource(properties = {
+ // @formatter:off
+ "cern.task.pageSize=5",
+ // @formatter:on
+})
+@ActiveProfiles(value = {"h2-test", "cern"})
+public class CernAccountLifecycleDisableUserTests extends TestSupport
+ implements LifecycleTestSupport {
+
+ @TestConfiguration
+ public static class TestConfig {
+ @Bean
+ @Primary
+ Clock mockClock() {
+ return TestClock.fixed(NOW, ZoneId.systemDefault());
+ }
+
+ @Bean
+ @Primary
+ CernHrDBApiService hrDb() {
+ return mock(CernHrDBApiService.class);
+ }
+ }
+
+ @Autowired
+ IamAccountRepository repo;
+
+ @Autowired
+ IamAccountService service;
+
+ @Autowired
+ CernHrLifecycleHandler cernHrLifecycleHandler;
+
+ @Autowired
+ ExpiredAccountsHandler expiredAccountsHandler;
+
+ @Autowired
+ CernHrDBApiService hrDb;
+
+ @Autowired
+ Clock clock;
+
+ IamAccount cernUser;
+
+ @Before
+ public void init() {
+
+ cernUser = IamAccount.newAccount();
+ cernUser.setUsername(CERN_USER);
+ cernUser.setUuid(CERN_USER_UUID);
+ cernUser.setActive(true);
+ cernUser.setEndTime(Date.from(NOW.plus(165, ChronoUnit.DAYS)));
+ cernUser.getUserInfo().setEmail(CERN_USER + "@example");
+ cernUser.getUserInfo().setGivenName("cern");
+ cernUser.getUserInfo().setFamilyName("user");
+ cernUser.getUserInfo().setEmailVerified(true);
+ service.createAccount(cernUser);
+ service.addLabel(cernUser, cernPersonIdLabel(CERN_PERSON_ID));
+ }
+
+ @After
+ public void teardown() {
+ reset(hrDb);
+ service.deleteAccount(cernUser);
+ }
+
+ private IamAccount loadAccount(String username) {
+ return repo.findByUuid(username).orElseThrow(assertionError(EXPECTED_ACCOUNT_NOT_FOUND));
+ }
+
+ @Test
+ public void testCernPersonIdNotFoundMeansUserEndTimeIsResetToCurrentDate() {
+
+ Date currentEndTime = cernUser.getEndTime();
+ when(hrDb.getHrDbPersonRecord(anyString())).thenReturn(Optional.empty());
+
+ cernHrLifecycleHandler.run();
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+ assertThat(testAccount.getEndTime().compareTo(currentEndTime) < 0, is(true));
+
+ Optional statusLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ Optional timestampLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
+ Optional messageLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
+
+ assertThat(statusLabel.isPresent(), is(true));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.EXPIRED.name()));
+
+ assertThat(timestampLabel.isPresent(), is(false));
+
+ assertThat(messageLabel.isPresent(), is(true));
+ assertThat(messageLabel.get().getValue(), is(format(NO_PERSON_FOUND_MESSAGE, CERN_PERSON_ID)));
+
+ ((TestClock) clock).fastForward(Duration.ofHours(36));
+
+ expiredAccountsHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+
+ Optional lifecycleStatusLabel =
+ testAccount.getLabelByName(ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL);
+
+ assertThat(lifecycleStatusLabel.isPresent(), is(true));
+ assertThat(lifecycleStatusLabel.get().getValue(), is(AccountLifecycleStatus.PENDING_SUSPENSION.name()));
+ }
+
+ @Test
+ public void testNoParticipationIsFoundMeansUserEndTimeIsResetToCurrentDate() {
+
+ Date currentEndTime = cernUser.getEndTime();
+
+ when(hrDb.getHrDbPersonRecord(anyString()))
+ .thenReturn(Optional.of(noParticipationsVoPerson(CERN_PERSON_ID)));
+
+ cernHrLifecycleHandler.run();
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+ assertThat(testAccount.getEndTime().compareTo(currentEndTime) < 0, is(true));
+
+ Optional statusLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ Optional timestampLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
+ Optional messageLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
+
+ assertThat(statusLabel.isPresent(), is(true));
+ assertThat(statusLabel.get().getValue(),
+ is(CernStatus.EXPIRED.name()));
+
+ assertThat(timestampLabel.isPresent(), is(false));
+
+ assertThat(messageLabel.isPresent(), is(true));
+ assertThat(messageLabel.get().getValue(), is(format(NO_PARTICIPATION_MESSAGE, "test")));
+
+ ((TestClock) clock).fastForward(Duration.ofHours(36));
+
+ expiredAccountsHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+
+ Optional lifecycleStatusLabel =
+ testAccount.getLabelByName(ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL);
+
+ assertThat(lifecycleStatusLabel.isPresent(), is(true));
+ assertThat(lifecycleStatusLabel.get().getValue(), is(AccountLifecycleStatus.PENDING_SUSPENSION.name()));
+
+ }
+
+ @Test
+ public void testEndTimeIsNotSynchronizedIfSkipLabelIsPresent() {
+
+ VOPersonDTO voPerson = voPerson(CERN_PERSON_ID);
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+ assertThat(testAccount.isActive(), is(true));
+ Date endTime = testAccount.getEndTime();
+ assertThat(endTime, not(is(voPerson.getParticipations().iterator().next().getEndDate())));
+
+ service.addLabel(testAccount, skipEndDateSyncLabel());
+ repo.save(testAccount);
+
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson));
+
+ cernHrLifecycleHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+ assertThat(testAccount.isActive(), is(true));
+ endTime = testAccount.getEndTime();
+ assertThat(endTime, not(is(voPerson.getParticipations().iterator().next().getEndDate())));
+
+ }
+
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java
index 210f9a906..10161dec5 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/CernAccountLifecycleTests.java
@@ -18,23 +18,20 @@
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL;
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus.PENDING_REMOVAL;
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.AccountLifecycleStatus.SUSPENDED;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.EXPIRED_MESSAGE;
import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.HR_DB_API_ERROR;
import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.IGNORE_MESSAGE;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_CERN_PREFIX;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_MESSAGE;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_STATUS;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_TIMESTAMP;
import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.NO_PARTICIPATION_MESSAGE;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.RESTORED_MESSAGE;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.VALID_MESSAGE;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.EXPIRED;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.IGNORED;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.Status.MEMBER;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.NO_PERSON_FOUND_MESSAGE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.SYNCHRONIZED_MESSAGE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_MESSAGE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_STATUS;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_TIMESTAMP;
import static java.lang.String.format;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
@@ -43,8 +40,10 @@
import java.time.Clock;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
+import java.util.Comparator;
import java.util.Date;
import java.util.Optional;
+import java.util.Random;
import java.util.UUID;
import org.junit.After;
@@ -64,12 +63,16 @@
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
+import com.google.common.collect.Sets;
+
import it.infn.mw.iam.IamLoginService;
import it.infn.mw.iam.api.registration.cern.CernHrDBApiService;
import it.infn.mw.iam.api.registration.cern.CernHrDbApiError;
+import it.infn.mw.iam.api.registration.cern.dto.ParticipationDTO;
import it.infn.mw.iam.api.registration.cern.dto.VOPersonDTO;
import it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler;
import it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler;
+import it.infn.mw.iam.core.lifecycle.cern.CernStatus;
import it.infn.mw.iam.core.user.IamAccountService;
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.model.IamLabel;
@@ -160,7 +163,8 @@ public void testUserSuspensionWorksAfterCernHrEndTimeUpdate() {
IamAccount testAccount = loadAccount(CERN_USER_UUID);
assertThat(testAccount.isActive(), is(true));
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(expiredVoPerson(CERN_PERSON_ID));
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID))
+ .thenReturn(Optional.of(expiredVoPerson(CERN_PERSON_ID)));
cernHrLifecycleHandler.run();
@@ -170,12 +174,12 @@ public void testUserSuspensionWorksAfterCernHrEndTimeUpdate() {
Optional cernStatusLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
assertThat(cernStatusLabel.isPresent(), is(true));
- assertThat(cernStatusLabel.get().getValue(), is(EXPIRED.name()));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
Optional cernMessageLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(cernMessageLabel.isPresent(), is(true));
- assertThat(cernMessageLabel.get().getValue(), is(EXPIRED_MESSAGE));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
Optional cernTimestampLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
@@ -189,11 +193,11 @@ public void testUserSuspensionWorksAfterCernHrEndTimeUpdate() {
cernStatusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
assertThat(cernStatusLabel.isPresent(), is(true));
- assertThat(cernStatusLabel.get().getValue(), is(EXPIRED.name()));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
cernMessageLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(cernMessageLabel.isPresent(), is(true));
- assertThat(cernMessageLabel.get().getValue(), is(EXPIRED_MESSAGE));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
cernTimestampLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
assertThat(cernTimestampLabel.isPresent(), is(false));
@@ -210,7 +214,8 @@ public void testUserRemovalWorksAfterCernHrEndTimeUpdate() {
IamAccount testAccount = loadAccount(CERN_USER_UUID);
assertThat(testAccount.isActive(), is(true));
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(removedVoPerson(CERN_PERSON_ID));
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID))
+ .thenReturn(Optional.of(removedVoPerson(CERN_PERSON_ID)));
cernHrLifecycleHandler.run();
@@ -220,12 +225,12 @@ public void testUserRemovalWorksAfterCernHrEndTimeUpdate() {
Optional cernStatusLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
assertThat(cernStatusLabel.isPresent(), is(true));
- assertThat(cernStatusLabel.get().getValue(), is(EXPIRED.name()));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
Optional cernMessageLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(cernMessageLabel.isPresent(), is(true));
- assertThat(cernMessageLabel.get().getValue(), is(EXPIRED_MESSAGE));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
Optional cernTimestampLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
@@ -240,7 +245,7 @@ public void testUserRemovalWorksAfterCernHrEndTimeUpdate() {
public void testLifecycleWorksForValidAccounts() {
VOPersonDTO voPerson = voPerson(CERN_PERSON_ID);
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(voPerson);
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson));
IamAccount testAccount = loadAccount(CERN_USER_UUID);
@@ -261,23 +266,104 @@ public void testLifecycleWorksForValidAccounts() {
Optional cernStatusLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
assertThat(cernStatusLabel.isPresent(), is(true));
- assertThat(cernStatusLabel.get().getValue(), is(MEMBER.name()));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
+
+ Optional cernMessageLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
+ assertThat(cernMessageLabel.isPresent(), is(true));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
+
+ Optional cernTimestampLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
+ assertThat(cernTimestampLabel.isPresent(), is(false));
+ }
+
+ @Test
+ public void testLifecycleWorksForAccountsWithOneValidParticipationAndOneExpired() {
+
+ VOPersonDTO voPerson = voPerson(CERN_PERSON_ID, getTestAccount(),
+ Sets.newHashSet(getLimitedParticipation("test"), getExpiredParticipation("test", 20)));
+
+ Comparator comparator = Comparator.comparing(ParticipationDTO::getEndDate);
+
+ ParticipationDTO highestParticipation =
+ voPerson.getParticipations().stream().max(comparator).get();
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson));
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+
+ cernHrLifecycleHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.getUserInfo().getGivenName(), is(voPerson.getFirstName()));
+ assertThat(testAccount.getUserInfo().getFamilyName(), is(voPerson.getName()));
+ assertThat(testAccount.getUserInfo().getEmail(), is(voPerson.getEmail()));
+ assertThat(testAccount.getEndTime(), is(highestParticipation.getEndDate()));
+
+ assertThat(testAccount.isActive(), is(true));
+
+ Optional cernStatusLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ assertThat(cernStatusLabel.isPresent(), is(true));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
+
+ Optional cernMessageLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
+ assertThat(cernMessageLabel.isPresent(), is(true));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
+
+ Optional cernTimestampLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
+ assertThat(cernTimestampLabel.isPresent(), is(false));
+ }
+
+ @Test
+ public void testLifecycleWorksForAccountsWithOneUnlimitedParticipationAndOneExpired() {
+
+ VOPersonDTO voPerson = voPerson(CERN_PERSON_ID, getTestAccount(),
+ Sets.newHashSet(getUnlimitedParticipation("test"), getExpiredParticipation("test", 20)));
+
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson));
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+
+ cernHrLifecycleHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.getUserInfo().getGivenName(), is(voPerson.getFirstName()));
+ assertThat(testAccount.getUserInfo().getFamilyName(), is(voPerson.getName()));
+ assertThat(testAccount.getUserInfo().getEmail(), is(voPerson.getEmail()));
+ assertThat(testAccount.getEndTime(), is(nullValue()));
+
+ assertThat(testAccount.isActive(), is(true));
+
+ Optional cernStatusLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ assertThat(cernStatusLabel.isPresent(), is(true));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
Optional cernMessageLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(cernMessageLabel.isPresent(), is(true));
- assertThat(cernMessageLabel.get().getValue(), is(VALID_MESSAGE));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
Optional cernTimestampLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
assertThat(cernTimestampLabel.isPresent(), is(false));
}
+
@Test
public void testLifecycleWhenVOPersonEndDateIsNull() {
VOPersonDTO voPerson = voPerson(CERN_PERSON_ID, null);
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(voPerson);
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson));
IamAccount testAccount = loadAccount(CERN_USER_UUID);
@@ -297,12 +383,12 @@ public void testLifecycleWhenVOPersonEndDateIsNull() {
Optional cernStatusLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
assertThat(cernStatusLabel.isPresent(), is(true));
- assertThat(cernStatusLabel.get().getValue(), is(MEMBER.name()));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
Optional cernMessageLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(cernMessageLabel.isPresent(), is(true));
- assertThat(cernMessageLabel.get().getValue(), is(VALID_MESSAGE));
+ assertThat(cernMessageLabel.get().getValue(), is(SYNCHRONIZED_MESSAGE));
Optional cernTimestampLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
@@ -312,7 +398,8 @@ public void testLifecycleWhenVOPersonEndDateIsNull() {
@Test
public void testRestoreLifecycleWorks() {
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(voPerson(CERN_PERSON_ID));
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID))
+ .thenReturn(Optional.of(voPerson(CERN_PERSON_ID)));
IamAccount testAccount = loadAccount(CERN_USER_UUID);
@@ -337,10 +424,10 @@ public void testRestoreLifecycleWorks() {
Optional iamStatusLabel = testAccount.getLabelByName(LIFECYCLE_STATUS_LABEL);
assertThat(cernStatusLabel.isPresent(), is(true));
- assertThat(cernStatusLabel.get().getValue(), is(MEMBER.name()));
+ assertThat(cernStatusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
assertThat(cernMessageLabel.isPresent(), is(true));
- assertThat(cernMessageLabel.get().getValue(), is(format(RESTORED_MESSAGE, clock.instant())));
+ assertThat(cernMessageLabel.get().getValue(), is(format(SYNCHRONIZED_MESSAGE)));
assertThat(cernTimestampLabel.isPresent(), is(false));
assertThat(iamStatusLabel.isPresent(), is(false));
@@ -365,7 +452,7 @@ public void testApiErrorIsHandled() {
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(statusLabel.isPresent(), is(true));
- assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.ERROR.name()));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.ERROR.name()));
assertThat(timestampLabel.isPresent(), is(false));
@@ -377,7 +464,7 @@ public void testApiErrorIsHandled() {
@Test
public void testApiReturnsNullVoPersonIsHandled() {
- when(hrDb.getHrDbPersonRecord(anyString())).thenReturn(null);
+ when(hrDb.getHrDbPersonRecord(anyString())).thenReturn(Optional.empty());
cernHrLifecycleHandler.run();
@@ -392,20 +479,60 @@ public void testApiReturnsNullVoPersonIsHandled() {
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(statusLabel.isPresent(), is(true));
- assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.ERROR.name()));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.EXPIRED.name()));
assertThat(timestampLabel.isPresent(), is(false));
assertThat(messageLabel.isPresent(), is(true));
- assertThat(messageLabel.get().getValue(), is(HR_DB_API_ERROR));
+ assertThat(messageLabel.get().getValue(), is(format(NO_PERSON_FOUND_MESSAGE, CERN_PERSON_ID)));
+
+ }
+
+ @Test
+ public void testNullEndTimeAndNoVoPersonFoundOnHR() {
+
+ when(hrDb.getHrDbPersonRecord(anyString())).thenReturn(Optional.empty());
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+ testAccount.setEndTime(null);
+ repo.save(testAccount);
+
+ cernHrLifecycleHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+ Optional statusLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ Optional timestampLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
+ Optional messageLabel =
+ testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
+
+ assertThat(statusLabel.isPresent(), is(true));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.EXPIRED.name()));
+
+ assertThat(timestampLabel.isPresent(), is(false));
+
+ assertThat(messageLabel.isPresent(), is(true));
+ assertThat(messageLabel.get().getValue(), is(format(NO_PERSON_FOUND_MESSAGE, CERN_PERSON_ID)));
+
+ cernHrLifecycleHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+ statusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ assertThat(statusLabel.isPresent(), is(true));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.EXPIRED.name()));
}
@Test
- public void testApiReturnsVoPersonWithNoParticipationsIsHandled() {
+ public void testVoPersonWithNoValidParticipationIsHandled() {
when(hrDb.getHrDbPersonRecord(anyString()))
- .thenReturn(noParticipationsVoPerson(CERN_PERSON_ID));
+ .thenReturn(Optional.of(noParticipationsVoPerson(CERN_PERSON_ID)));
cernHrLifecycleHandler.run();
@@ -420,19 +547,42 @@ public void testApiReturnsVoPersonWithNoParticipationsIsHandled() {
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(statusLabel.isPresent(), is(true));
- assertThat(statusLabel.get().getValue(), is(CernHrLifecycleHandler.Status.NOT_FOUND.name()));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.EXPIRED.name()));
assertThat(timestampLabel.isPresent(), is(false));
assertThat(messageLabel.isPresent(), is(true));
assertThat(messageLabel.get().getValue(), is(format(NO_PARTICIPATION_MESSAGE, "test")));
+ cernHrLifecycleHandler.run();
+
+ testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+ statusLabel = testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
+ assertThat(statusLabel.isPresent(), is(true));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.EXPIRED.name()));
+ }
+
+ @Test
+ public void testNoEmailVoPersonIsReturned() {
+
+ when(hrDb.getHrDbPersonRecord(anyString()))
+ .thenReturn(Optional.of(noEmailVoPerson(CERN_PERSON_ID)));
+
+ cernHrLifecycleHandler.run();
+
+ IamAccount testAccount = loadAccount(CERN_USER_UUID);
+
+ assertThat(testAccount.isActive(), is(true));
+ assertNotNull(testAccount.getUserInfo().getEmail());
}
@Test
public void testLifecycleNotRestoreAccountsSuspendedByAdmins() {
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(voPerson(CERN_PERSON_ID));
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID))
+ .thenReturn(Optional.of(voPerson(CERN_PERSON_ID)));
IamAccount testAccount = loadAccount(CERN_USER_UUID);
assertThat(testAccount.isActive(), is(true));
@@ -447,7 +597,7 @@ public void testLifecycleNotRestoreAccountsSuspendedByAdmins() {
Optional statusLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_STATUS);
assertThat(statusLabel.isPresent(), is(true));
- assertThat(statusLabel.get().getValue(), is(MEMBER.name()));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
Optional timestampLabel =
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
@@ -475,7 +625,7 @@ public void testIgnoreAccount() {
testAccount.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_MESSAGE);
assertThat(statusLabel.isPresent(), is(true));
- assertThat(statusLabel.get().getValue(), is(IGNORED.name()));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.IGNORED.name()));
assertThat(timestampLabel.isPresent(), is(false));
@@ -487,7 +637,7 @@ public void testIgnoreAccount() {
public void testPaginationWorks() {
when(hrDb.getHrDbPersonRecord(anyString()))
- .thenReturn(voPerson(String.valueOf((long) Math.random() * 100L)));
+ .thenReturn(Optional.of(voPerson(String.valueOf(new Random().nextLong() % 100L))));
Pageable pageRequest = PageRequest.of(0, 10, Direction.ASC, "username");
Page accountPage = repo.findAll(pageRequest);
@@ -509,7 +659,7 @@ public void testPaginationWorks() {
account.getLabelByPrefixAndName(LABEL_CERN_PREFIX, LABEL_TIMESTAMP);
assertThat(statusLabel.isPresent(), is(true));
- assertThat(statusLabel.get().getValue(), is(MEMBER.name()));
+ assertThat(statusLabel.get().getValue(), is(CernStatus.VO_MEMBER.name()));
assertThat(timestampLabel.isPresent(), is(false));
}
@@ -520,7 +670,7 @@ public void testEmailNotSynchronizedIfSkipEmailSyncIsPresent() {
VOPersonDTO voPerson = voPerson(CERN_PERSON_ID);
- when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(voPerson);
+ when(hrDb.getHrDbPersonRecord(CERN_PERSON_ID)).thenReturn(Optional.of(voPerson));
IamAccount testAccount = loadAccount(CERN_USER_UUID);
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java
index 3acb61bd4..6b3bde2cc 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/lifecycle/cern/LifecycleTestSupport.java
@@ -16,13 +16,15 @@
package it.infn.mw.iam.test.lifecycle.cern;
import static it.infn.mw.iam.core.lifecycle.ExpiredAccountsHandler.LIFECYCLE_STATUS_LABEL;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_CERN_PREFIX;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_IGNORE;
-import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleHandler.LABEL_SKIP_EMAIL_SYNCH;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_CERN_PREFIX;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_IGNORE;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_SKIP_EMAIL_SYNCH;
+import static it.infn.mw.iam.core.lifecycle.cern.CernHrLifecycleUtils.LABEL_SKIP_END_DATE_SYNCH;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
+import java.util.Set;
import java.util.function.Supplier;
import org.joda.time.LocalDate;
@@ -59,6 +61,10 @@ default IamLabel skipEmailSyncLabel() {
return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_SKIP_EMAIL_SYNCH).build();
}
+ default IamLabel skipEndDateSyncLabel() {
+ return IamLabel.builder().prefix(LABEL_CERN_PREFIX).name(LABEL_SKIP_END_DATE_SYNCH).build();
+ }
+
default IamLabel cernPersonIdLabel() {
return cernPersonIdLabel(CERN_PERSON_ID);
}
@@ -76,62 +82,94 @@ default IamLabel statusLabel(AccountLifecycleStatus s) {
}
default VOPersonDTO voPerson(String personId) {
- return voPerson(personId, LocalDate.now().plusDays(365).toDate());
+ return voPerson(personId, getTestAccount(), getTestParticipations());
}
default VOPersonDTO voPerson(String personId, Date endDate) {
- IamAccount account = IamAccount.newAccount();
- account.getUserInfo().setGivenName("TEST");
- account.getUserInfo().setFamilyName("USER");
- account.getUserInfo().setEmail("test@hr.cern");
- return voPerson(personId, account, "test", endDate);
+ Date startDate = endDate == null ? LocalDate.now().minusDays(365).toDate()
+ : LocalDate.fromDateFields(endDate).minusDays(365).toDate();
+ return voPerson(personId, getTestAccount(),
+ Sets.newHashSet(getParticipation("test", startDate, endDate)));
}
default VOPersonDTO noParticipationsVoPerson(String personId) {
- VOPersonDTO dto = voPerson(personId);
- dto.getParticipations().clear();
- return dto;
+ return voPerson(personId, getTestAccount(), Sets.newHashSet());
}
default VOPersonDTO expiredVoPerson(String personId) {
- VOPersonDTO dto = voPerson(personId);
- // Set endDate more than 7 days (suspension grace period) but less than 30 days (removal grace
- // period)
- dto.getParticipations().iterator().next().setEndDate(Date.from(NOW.minus(20, ChronoUnit.DAYS)));
- return dto;
+ return voPerson(personId, getTestAccount(),
+ Sets.newHashSet(getExpiredParticipation("test", 20)));
}
default VOPersonDTO removedVoPerson(String personId) {
- VOPersonDTO dto = voPerson(personId);
- // Set endDate more than 30 days (removal grace period)
- dto.getParticipations().iterator().next().setEndDate(Date.from(NOW.minus(40, ChronoUnit.DAYS)));
- return dto;
+ return voPerson(personId, getTestAccount(),
+ Sets.newHashSet(getExpiredParticipation("test", 40)));
}
- default VOPersonDTO voPerson(String personId, IamAccount account, String experiment,
- Date endDate) {
- VOPersonDTO dto = new VOPersonDTO();
- dto.setFirstName(account.getUserInfo().getGivenName());
- dto.setName(account.getUserInfo().getName());
- dto.setEmail(account.getUserInfo().getEmail());
- dto.setParticipations(Sets.newHashSet());
-
- dto.setId(Long.parseLong(personId));
-
- ParticipationDTO p = new ParticipationDTO();
-
- p.setExperiment(experiment);
- p.setStartDate(endDate);
+ default VOPersonDTO noEmailVoPerson(String personId) {
+ VOPersonDTO personDTO = voPerson(personId);
+ personDTO.setEmail(null);
+ return personDTO;
+ }
+ default InstituteDTO getTestInstitute() {
InstituteDTO i = new InstituteDTO();
i.setId("000001");
- i.setName("INFN");
+ i.setName("Istituto Nazionale di Fisica Nucleare");
i.setCountry("IT");
i.setTown("Bologna");
- p.setInstitute(i);
+ return i;
+ }
- dto.getParticipations().add(p);
+ default IamAccount getTestAccount() {
+ IamAccount account = IamAccount.newAccount();
+ account.getUserInfo().setGivenName("TEST");
+ account.getUserInfo().setFamilyName("USER");
+ account.getUserInfo().setEmail("test@hr.cern");
+ return account;
+ }
+
+ default ParticipationDTO getUnlimitedParticipation(String experiment) {
+ Date startDate = LocalDate.now().minusDays(365).toDate();
+ return getParticipation(experiment, startDate, null);
+ }
+
+ default ParticipationDTO getLimitedParticipation(String experiment) {
+ Date startDate = LocalDate.now().minusDays(365).toDate();
+ Date endDate = LocalDate.now().plusDays(365).toDate();
+ return getParticipation(experiment, startDate, endDate);
+ }
+
+ default Set getTestParticipations() {
+ return Sets.newHashSet(getLimitedParticipation("test"));
+ }
+
+ default ParticipationDTO getParticipation(String experiment, Date startDate, Date endDate) {
+ ParticipationDTO p = new ParticipationDTO();
+ p.setExperiment(experiment);
+ p.setStartDate(startDate);
+ p.setEndDate(endDate);
+ p.setInstitute(getTestInstitute());
+ return p;
+ }
+
+ default ParticipationDTO getExpiredParticipation(String experiment, int daysAgo) {
+ ParticipationDTO p = new ParticipationDTO();
+ p.setExperiment(experiment);
+ p.setStartDate(LocalDate.now().minusDays(daysAgo + 365).toDate());
+ p.setEndDate(LocalDate.now().minusDays(daysAgo).toDate());
+ p.setInstitute(getTestInstitute());
+ return p;
+ }
+ default VOPersonDTO voPerson(String personId, IamAccount account,
+ Set participations) {
+ VOPersonDTO dto = new VOPersonDTO();
+ dto.setFirstName(account.getUserInfo().getGivenName());
+ dto.setName(account.getUserInfo().getName());
+ dto.setEmail(account.getUserInfo().getEmail());
+ dto.setId(Long.parseLong(personId));
+ dto.setParticipations(participations);
return dto;
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/ExtendedAuthenticationTokenTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/ExtendedAuthenticationTokenTests.java
new file mode 100644
index 000000000..341677079
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/ExtendedAuthenticationTokenTests.java
@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+
+public class ExtendedAuthenticationTokenTests {
+
+ @Test
+ void testEqualsSameObjects() {
+ ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password");
+ ExtendedAuthenticationToken token2 = token1;
+
+ assertEquals(token1, token2, "Same objects should be equal");
+ }
+
+ @Test
+ void testEqualsIdenticalFields() {
+ ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password");
+ ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user1", "password");
+
+ assertEquals(token1, token2, "Objects with identical fields should be equal");
+ }
+
+ @Test
+ void testEqualsDifferentFields() {
+ ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password");
+ ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user2", "password");
+
+ assertNotEquals(token1, token2, "Objects with different fields should not be equal");
+ }
+
+ @Test
+ void testEqualsSubclassInstance() {
+ ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password");
+ AbstractAuthenticationToken token2 = new ExtendedAuthenticationToken("user1", "password");
+
+ assertEquals(token1, token2, "Subclass instances with identical fields should be equal");
+ }
+
+ @Test
+ void testHashCodeEqualObjects() {
+ ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password");
+ ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user1", "password");
+
+ assertEquals(token1.hashCode(), token2.hashCode(),
+ "Equal objects must have the same hash code");
+ }
+
+ @Test
+ void testHashCodeDifferentObjects() {
+ ExtendedAuthenticationToken token1 = new ExtendedAuthenticationToken("user1", "password");
+ ExtendedAuthenticationToken token2 = new ExtendedAuthenticationToken("user2", "password");
+
+ assertNotEquals(token1.hashCode(), token2.hashCode(),
+ "Unequal objects should not have the same hash code");
+ }
+
+ @Test
+ void testEqualsWithAuthorities() {
+ Set authorities = new HashSet<>();
+ authorities.add(() -> "ROLE_USER");
+
+ ExtendedAuthenticationToken token1 =
+ new ExtendedAuthenticationToken("user1", "password", authorities);
+ ExtendedAuthenticationToken token2 =
+ new ExtendedAuthenticationToken("user1", "password", authorities);
+
+ assertEquals(token1, token2, "Objects with identical authorities should be equal");
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpAuthenticationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpAuthenticationTests.java
new file mode 100644
index 000000000..079ad4bc3
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpAuthenticationTests.java
@@ -0,0 +1,162 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static org.hamcrest.CoreMatchers.is;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import io.restassured.RestAssured;
+import io.restassured.response.ValidatableResponse;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+import it.infn.mw.iam.test.TestUtils;
+import it.infn.mw.iam.test.util.annotation.IamRandomPortIntegrationTest;
+
+@RunWith(SpringRunner.class)
+@IamRandomPortIntegrationTest
+public class IamTotpAuthenticationTests {
+
+ @Autowired
+ IamTotpMfaRepository totpMfaRepo;
+
+ @Value("${local.server.port}")
+ private Integer iamPort;
+
+ public static final String TEST_CLIENT_ID = "client";
+ public static final String TEST_CLIENT_REDIRECT_URI =
+ "https://iam.local.io/iam-test-client/openid_connect_login";
+
+ public static final String LOCALHOST_URL_TEMPLATE = "http://localhost:%d";
+
+ public static final String RESPONSE_TYPE_CODE = "code";
+
+ public static final String SCOPE =
+ "openid profile scim:read scim:write offline_access iam:admin.read iam:admin.write";
+
+ private String loginUrl;
+ private String authorizeUrl;
+ private String verifyUrl;
+
+ @BeforeClass
+ public static void init() {
+ TestUtils.initRestAssured();
+
+ }
+
+ @Before
+ public void setup() {
+ RestAssured.port = iamPort;
+ loginUrl = String.format(LOCALHOST_URL_TEMPLATE + "/login", iamPort);
+ authorizeUrl = String.format(LOCALHOST_URL_TEMPLATE + "/authorize", iamPort);
+ verifyUrl = String.format(LOCALHOST_URL_TEMPLATE + "/iam/verify", iamPort);
+ }
+
+ @Test
+ public void testRedirectToVerifyPageAfterLogin() {
+
+ // @formatter:off
+ ValidatableResponse resp1 = RestAssured.given()
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", TEST_CLIENT_ID)
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .statusCode(HttpStatus.FOUND.value())
+ .header("Location", is(loginUrl));
+ // @formatter:on
+
+ // @formatter:off
+ RestAssured.given()
+ .formParam("username", "test-with-mfa")
+ .formParam("password", "password")
+ .formParam("submit", "Login")
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
+ .redirects().follow(false)
+ .when()
+ .post(loginUrl)
+ .then()
+ .statusCode(HttpStatus.FOUND.value())
+ .header("Location", is(verifyUrl));
+ // @formatter:on
+ }
+
+ @Test
+ public void testRedirectToAuthorizeUrlWhenTotpIsInactive() {
+
+ IamTotpMfa totp = totpMfaRepo.findByAccountId(Long.valueOf(1000)).orElseThrow();
+ totp.setActive(false);
+ totpMfaRepo.save(totp);
+
+ // @formatter:off
+ ValidatableResponse resp1 = RestAssured.given()
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", TEST_CLIENT_ID)
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .statusCode(HttpStatus.FOUND.value())
+ .header("Location", is(loginUrl));
+ // @formatter:on
+
+ // @formatter:off
+ RestAssured.given()
+ .formParam("username", "test-with-mfa")
+ .formParam("password", "password")
+ .formParam("submit", "Login")
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
+ .redirects().follow(false)
+ .when()
+ .post(loginUrl)
+ .then()
+ .statusCode(HttpStatus.FOUND.value());
+ // @formatter:on
+
+ // @formatter:off
+ RestAssured.given()
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", TEST_CLIENT_ID)
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.OK.value());
+ // @formatter:on
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaCommons.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaCommons.java
new file mode 100644
index 000000000..f27cc6d9b
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaCommons.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+public class IamTotpMfaCommons {
+ public static final String KEY_TO_ENCRYPT_DECRYPT = "define_me_please";
+ public static final String TOTP_MFA_SECRET = "secret";
+
+ public static final int DEFAULT_KEY_SIZE = 128;
+ public static final int DEFAULT_ITERATIONS = 65536;
+ public static final int DEFAULT_SALT_SIZE = 16;
+
+ public static final int ANOTHER_KEY_SIZE = 192;
+ public static final int ANOTHER_ITERATIONS = 6000;
+ public static final int ANOTHER_SALT_SIZE = 24;
+
+ public static final int INVALID_SALT_SIZE = 0;
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaEncryptionAndDecryptionUtilTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaEncryptionAndDecryptionUtilTests.java
new file mode 100644
index 000000000..4f1ed62ea
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaEncryptionAndDecryptionUtilTests.java
@@ -0,0 +1,170 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionHelper;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError;
+
+@RunWith(MockitoJUnitRunner.class)
+public class IamTotpMfaEncryptionAndDecryptionUtilTests extends IamTotpMfaCommons {
+
+ private static final IamTotpMfaEncryptionAndDecryptionHelper defaultModel = IamTotpMfaEncryptionAndDecryptionHelper
+ .getInstance();
+
+ @Before
+ public void setUp() {
+ defaultModel.setIterations(ANOTHER_ITERATIONS);
+ defaultModel.setKeyLengthInBits(ANOTHER_KEY_SIZE);
+ defaultModel.setSaltLengthInBytes(ANOTHER_SALT_SIZE);
+ }
+
+ @After
+ public void tearDown() {
+ defaultModel.setIterations(DEFAULT_ITERATIONS);
+ defaultModel.setKeyLengthInBits(DEFAULT_KEY_SIZE);
+ defaultModel.setSaltLengthInBytes(DEFAULT_SALT_SIZE);
+ }
+
+ @Test
+ public void testEncryptionAndDecryptionSecretMethods() throws IamTotpMfaInvalidArgumentError {
+ // Encrypt the plainText
+ String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ // Decrypt the cipherText
+ String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ assertEquals(TOTP_MFA_SECRET, plainText);
+ }
+
+ @Test
+ public void testDecryptSecretWithDifferentKey() throws IamTotpMfaInvalidArgumentError {
+ // Encrypt the plainText
+ String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ // Decrypt the cipherText with a different key
+ IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText, "NOT_THE_SAME_KEY");
+ });
+
+ assertTrue(thrownException.getMessage().startsWith("An error occurred while decrypting"));
+
+ // Decrypt the cipherText with a the same key used for encryption.
+ String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ assertEquals(TOTP_MFA_SECRET, plainText);
+ }
+
+ @Test
+ public void testEncryptSecretWithTamperedCipher() throws IamTotpMfaInvalidArgumentError {
+ // Encrypt the plainText
+ String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ String modifyCipher = cipherText.substring(1);
+ String tamperedCipher = "i" + modifyCipher;
+
+ if (!tamperedCipher.substring(0, 3).equals(cipherText.substring(0, 3))) {
+
+ IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ // Decrypt the tampered cipherText
+ IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(tamperedCipher, KEY_TO_ENCRYPT_DECRYPT);
+ });
+
+ // Always throws an error because user have tampered the cipherText.
+ assertTrue(thrownException.getMessage().contains("An error occurred while decrypting"));
+ } else {
+
+ // Decrypt the right cipherText with a the same key used for encryption.
+ String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ assertEquals(TOTP_MFA_SECRET, plainText);
+ }
+ }
+
+ @Test
+ public void testEncryptSecretWithEmptyPlainText() throws IamTotpMfaInvalidArgumentError {
+
+ IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ // Try to encrypt the empty plainText
+ IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(null, KEY_TO_ENCRYPT_DECRYPT);
+ });
+
+ // Always throws an error because we have passed empty plaintext.
+ assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide"));
+ }
+
+ @Test
+ public void testEncryptSecretWithInvalidSaltSize() throws IamTotpMfaInvalidArgumentError {
+ defaultModel.setSaltLengthInBytes(INVALID_SALT_SIZE);
+
+ IamTotpMfaInvalidArgumentError throwException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT);
+ });
+
+ assertTrue(throwException.getCause().getMessage().startsWith("the salt parameter must not"));
+ }
+
+ @Test
+ public void testDecryptSecretWithEmptyPlainText() throws IamTotpMfaInvalidArgumentError {
+
+ IamTotpMfaInvalidArgumentError thrownException = assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(null, KEY_TO_ENCRYPT_DECRYPT);
+ });
+
+ // Always throws an error because we have passed empty ciphertext.
+ assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide"));
+ }
+
+ @Test
+ public void testEncryptSecretWithAesCipher_CBC_Mode() throws IamTotpMfaInvalidArgumentError {
+ defaultModel.setShortFormOfCipherMode(
+ IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.CBC);
+ defaultModel.setModeOfOperation(
+ IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.CBC.getCipherMode());
+
+ // Encrypt the plainText with CBC Cipher mode
+ String cipherText = IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(TOTP_MFA_SECRET,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ // Decrypt the cipherText with CBC Cipher mode
+ String plainText = IamTotpMfaEncryptionAndDecryptionUtil.decryptSecret(cipherText,
+ KEY_TO_ENCRYPT_DECRYPT);
+
+ defaultModel.setShortFormOfCipherMode(
+ IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.GCM);
+ defaultModel.setModeOfOperation(
+ IamTotpMfaEncryptionAndDecryptionHelper.AesCipherModes.GCM.getCipherMode());
+
+ // Expect encryption and decryption works as expected in CBC Cipher mode.
+ assertEquals(TOTP_MFA_SECRET, plainText);
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java
new file mode 100644
index 000000000..92fdfa4dc
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTestSupport.java
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamAuthority;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+
+public class IamTotpMfaServiceTestSupport extends IamTotpMfaCommons {
+
+ public static final String PASSWORD = "password";
+
+ public static final String TOTP_MFA_ACCOUNT_UUID = "b3e7dd7f-a1ac-eda0-371d-b902a6c5cee2";
+ public static final String TOTP_MFA_ACCOUNT_USERNAME = "totp";
+ public static final String TOTP_MFA_ACCOUNT_EMAIL = "totp@example.org";
+ public static final String TOTP_MFA_ACCOUNT_GIVEN_NAME = "Totp";
+ public static final String TOTP_MFA_ACCOUNT_FAMILY_NAME = "Mfa";
+
+ public static final String TOTP_CODE = "123456";
+
+ protected final IamAccount TOTP_MFA_ACCOUNT;
+ protected final IamAuthority ROLE_USER_AUTHORITY;
+
+ protected final IamTotpMfa TOTP_MFA;
+
+ public IamTotpMfaServiceTestSupport() {
+ ROLE_USER_AUTHORITY = new IamAuthority("ROLE_USER");
+
+ TOTP_MFA_ACCOUNT = IamAccount.newAccount();
+ TOTP_MFA_ACCOUNT.setUuid(TOTP_MFA_ACCOUNT_UUID);
+ TOTP_MFA_ACCOUNT.setUsername(TOTP_MFA_ACCOUNT_USERNAME);
+ TOTP_MFA_ACCOUNT.getUserInfo().setEmail(TOTP_MFA_ACCOUNT_EMAIL);
+ TOTP_MFA_ACCOUNT.getUserInfo().setGivenName(TOTP_MFA_ACCOUNT_GIVEN_NAME);
+ TOTP_MFA_ACCOUNT.getUserInfo().setFamilyName(TOTP_MFA_ACCOUNT_FAMILY_NAME);
+
+ TOTP_MFA = new IamTotpMfa();
+ TOTP_MFA.setAccount(TOTP_MFA_ACCOUNT);
+ TOTP_MFA.setSecret(getEncryptedCode(TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT));
+ TOTP_MFA.setActive(true);
+
+ TOTP_MFA.touch();
+ }
+
+ public IamAccount cloneAccount(IamAccount account) {
+ IamAccount newAccount = IamAccount.newAccount();
+ newAccount.setUuid(account.getUuid());
+ newAccount.setUsername(account.getUsername());
+ newAccount.getUserInfo().setEmail(account.getUserInfo().getEmail());
+ newAccount.getUserInfo().setGivenName(account.getUserInfo().getGivenName());
+ newAccount.getUserInfo().setFamilyName(account.getUserInfo().getFamilyName());
+
+ newAccount.touch();
+
+ return newAccount;
+ }
+
+ public IamTotpMfa cloneTotpMfa(IamTotpMfa totpMfa) {
+ IamTotpMfa newTotpMfa = new IamTotpMfa();
+ newTotpMfa.setAccount(totpMfa.getAccount());
+ newTotpMfa.setSecret(totpMfa.getSecret());
+ newTotpMfa.setActive(totpMfa.isActive());
+
+ newTotpMfa.touch();
+
+ return newTotpMfa;
+ }
+
+ public String getEncryptedCode(String plaintext, String key) {
+ return IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(plaintext, key);
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java
new file mode 100644
index 000000000..7f18fde5e
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/IamTotpMfaServiceTests.java
@@ -0,0 +1,291 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.context.ApplicationEvent;
+import org.springframework.context.ApplicationEventPublisher;
+
+import dev.samstevens.totp.code.CodeVerifier;
+import dev.samstevens.totp.secret.SecretGenerator;
+import it.infn.mw.iam.api.account.multi_factor_authentication.DefaultIamTotpMfaService;
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppDisabledEvent;
+import it.infn.mw.iam.audit.events.account.multi_factor_authentication.AuthenticatorAppEnabledEvent;
+import it.infn.mw.iam.config.mfa.IamTotpMfaProperties;
+import it.infn.mw.iam.core.user.IamAccountService;
+import it.infn.mw.iam.core.user.exception.MfaSecretAlreadyBoundException;
+import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
+import it.infn.mw.iam.core.user.exception.TotpMfaAlreadyEnabledException;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+import it.infn.mw.iam.util.mfa.IamTotpMfaInvalidArgumentError;
+
+@RunWith(MockitoJUnitRunner.class)
+public class IamTotpMfaServiceTests extends IamTotpMfaServiceTestSupport {
+
+ private IamTotpMfaService service;
+
+ @Mock
+ private IamTotpMfaRepository repository;
+
+ @Mock
+ private SecretGenerator secretGenerator;
+
+ @Mock
+ private IamAccountService iamAccountService;
+
+ @Mock
+ private CodeVerifier codeVerifier;
+
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ @Mock
+ private IamTotpMfaProperties iamTotpMfaProperties;
+
+ @Captor
+ private ArgumentCaptor eventCaptor;
+
+ @Before
+ public void setup() {
+ when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn(KEY_TO_ENCRYPT_DECRYPT);
+
+ when(secretGenerator.generate()).thenReturn("test_secret");
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA));
+ when(iamAccountService.saveAccount(TOTP_MFA_ACCOUNT)).thenAnswer(i -> i.getArguments()[0]);
+ when(codeVerifier.isValidCode(anyString(), anyString())).thenReturn(true);
+
+ service = new DefaultIamTotpMfaService(iamAccountService, repository, secretGenerator,
+ codeVerifier, eventPublisher, iamTotpMfaProperties);
+ }
+
+ @After
+ public void tearDown() {
+ reset(secretGenerator, repository, iamAccountService, codeVerifier);
+ }
+
+ @Test
+ public void testAssignsTotpMfaToAccount() {
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty());
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ IamTotpMfa totpMfa = service.addTotpMfaSecret(account);
+ verify(repository, times(1)).save(totpMfa);
+ verify(secretGenerator, times(1)).generate();
+
+ assertNotNull(totpMfa.getSecret());
+ assertFalse(totpMfa.isActive());
+ assertThat(totpMfa.getAccount(), equalTo(account));
+ }
+
+ @Test(expected = MfaSecretAlreadyBoundException.class)
+ public void testAddMfaSecret_whenMfaSecretAssignedFails() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ try {
+ service.addTotpMfaSecret(account);
+ } catch (MfaSecretAlreadyBoundException e) {
+ assertThat(e.getMessage(),
+ equalTo("A multi-factor secret is already assigned to this account"));
+ throw e;
+ }
+ }
+
+ @Test
+ public void testAddMfaSecretWhenTotpIsNotActive() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ TOTP_MFA.setActive(false);
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA));
+ IamTotpMfa totpMfa = service.addTotpMfaSecret(account);
+ assertFalse(totpMfa.isActive());
+ }
+
+ @Test
+ public void testAddTotpMfaSecret_whenPasswordIsEmpty() {
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty());
+ when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn("");
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ IamTotpMfaInvalidArgumentError thrownException =
+ assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ // Decrypt the cipherText with empty key
+ service.addTotpMfaSecret(account);
+ });
+
+ assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide"));
+ }
+
+ @Test
+ public void testEnablesTotpMfa() throws Exception {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+ totpMfa.setSecret(IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret("secret",
+ iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()));
+ totpMfa.setActive(false);
+ totpMfa.setAccount(account);
+
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa));
+
+ service.enableTotpMfa(account);
+ verify(repository, times(1)).save(totpMfa);
+ verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture());
+
+ ApplicationEvent event = eventCaptor.getValue();
+ assertThat(event, instanceOf(AuthenticatorAppEnabledEvent.class));
+
+ AuthenticatorAppEnabledEvent e = (AuthenticatorAppEnabledEvent) event;
+ assertTrue(e.getTotpMfa().isActive());
+ assertThat(e.getTotpMfa().getAccount(), equalTo(account));
+ }
+
+ @Test(expected = TotpMfaAlreadyEnabledException.class)
+ public void testEnableTotpMfa_whenTotpMfaEnabledFails() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ try {
+ service.enableTotpMfa(account);
+ } catch (TotpMfaAlreadyEnabledException e) {
+ assertThat(e.getMessage(), equalTo("TOTP MFA is already enabled on this account"));
+ throw e;
+ }
+ }
+
+ @Test(expected = MfaSecretNotFoundException.class)
+ public void testEnablesTotpMfa_whenNoMfaSecretAssignedFails() {
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty());
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ try {
+ service.enableTotpMfa(account);
+ } catch (MfaSecretNotFoundException e) {
+ assertThat(e.getMessage(), equalTo("No multi-factor secret is attached to this account"));
+ throw e;
+ }
+ }
+
+ @Test
+ public void testDisablesTotpMfa() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+
+ service.disableTotpMfa(account);
+ verify(repository, times(1)).delete(totpMfa);
+ verify(iamAccountService, times(1)).saveAccount(account);
+ verify(eventPublisher, times(1)).publishEvent(eventCaptor.capture());
+
+ ApplicationEvent event = eventCaptor.getValue();
+ assertThat(event, instanceOf(AuthenticatorAppDisabledEvent.class));
+
+ AuthenticatorAppDisabledEvent e = (AuthenticatorAppDisabledEvent) event;
+ assertThat(e.getTotpMfa().getAccount(), equalTo(account));
+ }
+
+ @Test(expected = MfaSecretNotFoundException.class)
+ public void testDisablesTotpMfa_whenNoMfaSecretAssignedFails() {
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty());
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ try {
+ service.disableTotpMfa(account);
+ } catch (MfaSecretNotFoundException e) {
+ assertThat(e.getMessage(), equalTo("No multi-factor secret is attached to this account"));
+ throw e;
+ }
+ }
+
+ @Test
+ public void testVerifyTotp_WithNoMultiFactorSecretAttached() {
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.empty());
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ MfaSecretNotFoundException thrownException =
+ assertThrows(MfaSecretNotFoundException.class, () -> {
+ service.verifyTotp(account, TOTP_CODE);
+ });
+
+ assertTrue(thrownException.getMessage().startsWith("No multi-factor secret is attached"));
+ }
+
+ @Test
+ public void testVerifyTotp() {
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa));
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ assertTrue(service.verifyTotp(account, TOTP_CODE));
+ }
+
+ @Test
+ public void testVerifyTotp_WithEmptyPasswordForDecryption() {
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa));
+ when(iamTotpMfaProperties.getPasswordToEncryptOrDecrypt()).thenReturn("");
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ IamTotpMfaInvalidArgumentError thrownException =
+ assertThrows(IamTotpMfaInvalidArgumentError.class, () -> {
+ service.verifyTotp(account, TOTP_CODE);
+ });
+
+ assertTrue(thrownException.getMessage().startsWith("Please ensure that you provide"));
+ }
+
+ @Test
+ public void testVerifyTotp_WithCodeNotValid() {
+ IamTotpMfa totpMfa = cloneTotpMfa(TOTP_MFA);
+
+ when(repository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(totpMfa));
+ when(codeVerifier.isValidCode(anyString(), anyString())).thenReturn(false);
+
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+
+ assertFalse(service.verifyTotp(account, TOTP_CODE));
+ }
+
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.java
new file mode 100644
index 000000000..db190f751
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MfaVerifyControllerTests.java
@@ -0,0 +1,111 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.MfaVerifyController.MFA_VERIFY_URL;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import it.infn.mw.iam.api.common.NoSuchAccountError;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+
+@RunWith(SpringRunner.class)
+@IamMockMvcIntegrationTest
+public class MfaVerifyControllerTests extends MultiFactorTestSupport {
+
+ private MockMvc mvc;
+
+ @Autowired
+ private WebApplicationContext context;
+
+ @MockBean
+ private IamAccountRepository accountRepository;
+
+ @MockBean
+ private IamTotpMfaRepository totpMfaRepository;
+
+ @Before
+ public void setup() {
+ when(accountRepository.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT));
+ when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT));
+
+ mvc =
+ MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build();
+ }
+
+ @Test
+ @WithMockUser(username = "test-mfa-user", authorities = {"ROLE_PRE_AUTHENTICATED"})
+ public void testGetVerifyMfaView() throws Exception {
+ mvc.perform(get(MFA_VERIFY_URL))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("factors"));
+
+ verify(totpMfaRepository, times(1)).findByAccount(TOTP_MFA_ACCOUNT);
+ }
+
+ @Test
+ @WithMockUser(username = "test-mfa-user", authorities = {"ROLE_PRE_AUTHENTICATED"})
+ public void testGetVerifyMfaViewWhenTotpAlreadyPresent() throws Exception {
+ when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA));
+ mvc.perform(get(MFA_VERIFY_URL))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("factors"));
+
+ verify(totpMfaRepository, times(1)).findByAccount(TOTP_MFA_ACCOUNT);
+ }
+
+ @Test
+ @WithMockUser(username = "test-mfa-user", authorities = {"ROLE_PRE_AUTHENTICATED"})
+ public void testGetVerifyMfaViewThrowsNoSuchAccountError() throws Exception {
+ when(accountRepository.findByUsername(TOTP_USERNAME))
+ .thenThrow(new NoSuchAccountError(String.format("Account not found for username '%s'", TOTP_USERNAME)));
+ mvc.perform(get(MFA_VERIFY_URL)).andExpect(status().isBadRequest());
+
+ verify(totpMfaRepository, times(0)).findByAccount(TOTP_MFA_ACCOUNT);
+ }
+
+ @Test
+ public void testGetMfaVerifyViewNoAuthenticationIsUnauthorized() throws Exception {
+ mvc.perform(get(MFA_VERIFY_URL)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser
+ public void testGetMfaVerifyViewWithFullAuthenticationIsForbidden() throws Exception {
+ mvc.perform(get(MFA_VERIFY_URL)).andExpect(status().isForbidden());
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorSettingsControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorSettingsControllerTests.java
new file mode 100644
index 000000000..7ab83a4ea
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorSettingsControllerTests.java
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL;
+import static it.infn.mw.iam.api.account.multi_factor_authentication.MultiFactorSettingsController.MULTI_FACTOR_SETTINGS_URL;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+import it.infn.mw.iam.test.util.WithAnonymousUser;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+
+@RunWith(SpringRunner.class)
+@IamMockMvcIntegrationTest
+public class MultiFactorSettingsControllerTests extends MultiFactorTestSupport {
+ private MockMvc mvc;
+ @Autowired
+ private WebApplicationContext context;
+ @MockBean
+ private IamAccountRepository accountRepository;
+ @MockBean
+ private IamTotpMfaRepository totpMfaRepository;
+
+ @Before
+ public void setup() {
+ when(accountRepository.findByUuid(TOTP_UUID)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT));
+ when(accountRepository.findByUsername(TOTP_USERNAME)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT));
+ when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA));
+
+ mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build();
+ }
+
+ @Test
+ @WithAnonymousUser
+ public void testGetMfaAccountSettingNoAuthenticationFails() throws Exception {
+ mvc.perform(get(MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL, TOTP_UUID)).andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = "ADMIN")
+ public void testGetMfaAccountSettingWorksForAdmin() throws Exception {
+ mvc.perform(get(MULTI_FACTOR_SETTINGS_FOR_ACCOUNT_URL, TOTP_UUID))
+ .andExpect(status().isOk())
+ .andExpect((jsonPath("$.authenticatorAppActive", equalTo(true))));
+ }
+
+ @Test
+ @WithMockUser(username = "test-mfa-user", roles = "USER")
+ public void testGetMfaAccountSettingWorksForAuthenticatedUser() throws Exception {
+ mvc.perform(get(MULTI_FACTOR_SETTINGS_URL))
+ .andExpect(status().isOk())
+ .andExpect((jsonPath("$.authenticatorAppActive", equalTo(true))));
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java
new file mode 100644
index 000000000..f97302253
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTestSupport.java
@@ -0,0 +1,120 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamTotpMfa;
+import it.infn.mw.iam.util.mfa.IamTotpMfaEncryptionAndDecryptionUtil;
+
+public class MultiFactorTestSupport extends IamTotpMfaCommons{
+ public static final String TEST_USERNAME = "test-user";
+ public static final String TEST_UUID = "a23deabf-88a7-47af-84b5-1d535a1b267c";
+ public static final String TEST_EMAIL = "test@example.org";
+ public static final String TEST_GIVEN_NAME = "Test";
+ public static final String TEST_FAMILY_NAME = "User";
+ public static final String TOTP_USERNAME = "test-mfa-user";
+ public static final String TOTP_UUID = "ceb173b4-28e3-43ad-aaf7-15d3730e2b90";
+ public static final String TOTP_EMAIL = "test-mfa@example.org";
+ public static final String TOTP_GIVEN_NAME = "Test";
+ public static final String TOTP_FAMILY_NAME = "Mfa";
+
+ protected final IamAccount TEST_ACCOUNT;
+ protected final IamAccount TOTP_MFA_ACCOUNT;
+ protected final IamTotpMfa TOTP_MFA;
+
+ public MultiFactorTestSupport() {
+ TEST_ACCOUNT = IamAccount.newAccount();
+ TEST_ACCOUNT.setUsername(TEST_USERNAME);
+ TEST_ACCOUNT.setUuid(TEST_UUID);
+ TEST_ACCOUNT.getUserInfo().setEmail(TEST_EMAIL);
+ TEST_ACCOUNT.getUserInfo().setGivenName(TEST_GIVEN_NAME);
+ TEST_ACCOUNT.getUserInfo().setFamilyName(TEST_FAMILY_NAME);
+
+ TEST_ACCOUNT.touch();
+
+ TOTP_MFA_ACCOUNT = IamAccount.newAccount();
+ TOTP_MFA_ACCOUNT.setUsername(TOTP_USERNAME);
+ TOTP_MFA_ACCOUNT.setUuid(TOTP_UUID);
+ TOTP_MFA_ACCOUNT.getUserInfo().setEmail(TOTP_EMAIL);
+ TOTP_MFA_ACCOUNT.getUserInfo().setGivenName(TOTP_GIVEN_NAME);
+ TOTP_MFA_ACCOUNT.getUserInfo().setFamilyName(TOTP_FAMILY_NAME);
+
+ TOTP_MFA_ACCOUNT.touch();
+
+ TOTP_MFA = new IamTotpMfa();
+ TOTP_MFA.setAccount(TOTP_MFA_ACCOUNT);
+ TOTP_MFA.setSecret(
+ IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(
+ TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT));
+ TOTP_MFA.setActive(true);
+ TOTP_MFA.touch();
+ }
+
+ protected void resetTestAccount() {
+ TEST_ACCOUNT.setUsername(TEST_USERNAME);
+ TEST_ACCOUNT.setUuid(TEST_UUID);
+ TEST_ACCOUNT.getUserInfo().setEmail(TEST_EMAIL);
+ TEST_ACCOUNT.getUserInfo().setGivenName(TEST_GIVEN_NAME);
+ TEST_ACCOUNT.getUserInfo().setFamilyName(TEST_FAMILY_NAME);
+
+ TEST_ACCOUNT.touch();
+ }
+
+ protected void resetTotpAccount() {
+ TOTP_MFA_ACCOUNT.setUsername(TOTP_USERNAME);
+ TOTP_MFA_ACCOUNT.setUuid(TOTP_UUID);
+ TOTP_MFA_ACCOUNT.getUserInfo().setEmail(TOTP_EMAIL);
+ TOTP_MFA_ACCOUNT.getUserInfo().setGivenName(TOTP_GIVEN_NAME);
+ TOTP_MFA_ACCOUNT.getUserInfo().setFamilyName(TOTP_FAMILY_NAME);
+
+ TOTP_MFA_ACCOUNT.touch();
+
+ TOTP_MFA.setAccount(TOTP_MFA_ACCOUNT);
+ TOTP_MFA.setSecret(
+ IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(
+ TOTP_MFA_SECRET, KEY_TO_ENCRYPT_DECRYPT));
+ TOTP_MFA.setActive(true);
+ TOTP_MFA.touch();
+ }
+
+ protected IamAccount cloneAccount(IamAccount account) {
+ IamAccount newAccount = IamAccount.newAccount();
+ newAccount.setUuid(account.getUuid());
+ newAccount.setUsername(account.getUsername());
+ newAccount.getUserInfo().setEmail(account.getUserInfo().getEmail());
+ newAccount.getUserInfo().setGivenName(account.getUserInfo().getGivenName());
+ newAccount.getUserInfo().setFamilyName(account.getUserInfo().getFamilyName());
+
+ newAccount.touch();
+
+ return newAccount;
+ }
+
+ protected IamTotpMfa cloneTotpMfa(IamTotpMfa totpMfa) {
+ IamTotpMfa newTotpMfa = new IamTotpMfa();
+ newTotpMfa.setAccount(totpMfa.getAccount());
+ newTotpMfa.setSecret(totpMfa.getSecret());
+ newTotpMfa.setActive(totpMfa.isActive());
+
+ newTotpMfa.touch();
+
+ return newTotpMfa;
+ }
+
+ public String getEncryptedCode(String plaintext, String key) {
+ return IamTotpMfaEncryptionAndDecryptionUtil.encryptSecret(plaintext, key);
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTotpCheckProviderTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTotpCheckProviderTests.java
new file mode 100644
index 000000000..ea9e6aa80
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorTotpCheckProviderTests.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.security.authentication.BadCredentialsException;
+
+import it.infn.mw.iam.api.account.multi_factor_authentication.IamTotpMfaService;
+import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorTotpCheckProvider;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+import it.infn.mw.iam.core.user.exception.MfaSecretNotFoundException;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+
+public class MultiFactorTotpCheckProviderTests extends IamTotpMfaServiceTestSupport {
+
+ private MultiFactorTotpCheckProvider multiFactorTotpCheckProvider;
+
+ @Mock
+ private IamAccountRepository accountRepo;
+
+ @Mock
+ private IamTotpMfaService totpMfaService;
+
+ @Mock
+ private ExtendedAuthenticationToken token;
+
+ @Before
+ public void setup() {
+ MockitoAnnotations.openMocks(this);
+ multiFactorTotpCheckProvider = new MultiFactorTotpCheckProvider(accountRepo, totpMfaService);
+ }
+
+ @Test
+ public void authenticateReturnsNullWhenTotpIsNull() {
+ when(token.getTotp()).thenReturn(null);
+ assertNull(multiFactorTotpCheckProvider.authenticate(token));
+ }
+
+ @Test
+ public void authenticateThrowsBadCredentialsExceptionWhenAccountNotFound() {
+ when(token.getTotp()).thenReturn("123456");
+ when(token.getName()).thenReturn("username");
+ when(accountRepo.findByUsername("username")).thenReturn(Optional.empty());
+
+ assertThrows(BadCredentialsException.class,
+ () -> multiFactorTotpCheckProvider.authenticate(token));
+ }
+
+ @Test
+ public void authenticatePropagatesMfaSecretNotFoundException() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ when(token.getName()).thenReturn("totp");
+ when(token.getTotp()).thenReturn("123456");
+ when(accountRepo.findByUsername("totp")).thenReturn(Optional.of(account));
+ when(totpMfaService.verifyTotp(account, "123456"))
+ .thenThrow(new MfaSecretNotFoundException("Mfa secret not found"));
+
+ assertThrows(MfaSecretNotFoundException.class,
+ () -> multiFactorTotpCheckProvider.authenticate(token));
+ }
+
+ @Test
+ public void authenticateThrowsBadCredentialsExceptionWhenTotpIsInvalid() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ when(token.getName()).thenReturn("totp");
+ when(token.getTotp()).thenReturn("123456");
+ when(accountRepo.findByUsername(anyString())).thenReturn(Optional.of(account));
+ when(totpMfaService.verifyTotp(account, "123456")).thenReturn(false);
+
+ assertThrows(BadCredentialsException.class,
+ () -> multiFactorTotpCheckProvider.authenticate(token));
+ }
+
+ @Test
+ public void authenticateReturnsSuccessfulAuthenticationWhenTotpIsValid() {
+ IamAccount account = cloneAccount(TOTP_MFA_ACCOUNT);
+ when(token.getName()).thenReturn("totp");
+ when(token.getTotp()).thenReturn("123456");
+ when(accountRepo.findByUsername("totp")).thenReturn(Optional.of(account));
+ when(totpMfaService.verifyTotp(account, "123456")).thenReturn(true);
+
+ assertNotNull(multiFactorTotpCheckProvider.authenticate(token));
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorVerificationFilterTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorVerificationFilterTests.java
new file mode 100644
index 000000000..6abfe0b95
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/MultiFactorVerificationFilterTests.java
@@ -0,0 +1,170 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.multi_factor_authentication;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.file.ProviderNotFoundException;
+import java.util.ArrayList;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+
+import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+
+public class MultiFactorVerificationFilterTests {
+
+ @Mock
+ private AuthenticationManager authenticationManager;
+
+ @Mock
+ private AuthenticationSuccessHandler successHandler;
+
+ @Mock
+ private AuthenticationFailureHandler failureHandler;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private Authentication authentication;
+
+ @InjectMocks
+ private MultiFactorVerificationFilter multiFactorVerificationFilter;
+
+ @BeforeEach
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ public void testAuthenticationSuccess() throws Exception {
+ Authentication mockAuth = mock(ExtendedAuthenticationToken.class);
+ when(mockAuth.getName()).thenReturn("username");
+
+ SecurityContextHolder.getContext().setAuthentication(mockAuth);
+
+ Authentication mockAuthenticatedToken =
+ new ExtendedAuthenticationToken("username", null, new ArrayList<>());
+ when(authenticationManager.authenticate(any(Authentication.class)))
+ .thenReturn(mockAuthenticatedToken);
+
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getParameter("totp")).thenReturn("123456");
+
+ Authentication result = multiFactorVerificationFilter.attemptAuthentication(request, response);
+
+ assertNotNull(result);
+ assertEquals(mockAuthenticatedToken, result);
+ }
+
+ @Test
+ public void testAuthenticationFailureDueToUnsupportedAuthnMethod() throws Exception {
+ Authentication mockAuth = mock(ExtendedAuthenticationToken.class);
+ when(mockAuth.getName()).thenReturn("username");
+
+ SecurityContextHolder.getContext().setAuthentication(mockAuth);
+
+ Authentication mockAuthenticatedToken =
+ new ExtendedAuthenticationToken("username", null, new ArrayList<>());
+ when(authenticationManager.authenticate(any(Authentication.class)))
+ .thenReturn(mockAuthenticatedToken);
+
+ when(request.getMethod()).thenReturn("GET");
+
+ assertThrows(AuthenticationServiceException.class,
+ () -> multiFactorVerificationFilter.attemptAuthentication(request, response));
+ }
+
+ @Test
+ public void testAuthenticationFailureDueToBadAuthn() throws Exception {
+ Authentication mockAuth = mock(UsernamePasswordAuthenticationToken.class);
+ when(mockAuth.getName()).thenReturn("username");
+
+ SecurityContextHolder.getContext().setAuthentication(mockAuth);
+
+ Authentication mockAuthenticatedToken =
+ new UsernamePasswordAuthenticationToken("username", null, new ArrayList<>());
+ when(authenticationManager.authenticate(any(Authentication.class)))
+ .thenReturn(mockAuthenticatedToken);
+
+ when(request.getMethod()).thenReturn("POST");
+
+ assertThrows(AuthenticationServiceException.class,
+ () -> multiFactorVerificationFilter.attemptAuthentication(request, response));
+ }
+
+ @Test
+ public void testAuthenticationFailureDueToInvalidTOTP() throws Exception {
+ Authentication mockAuth = mock(ExtendedAuthenticationToken.class);
+ when(mockAuth.getName()).thenReturn("username");
+
+ SecurityContextHolder.getContext().setAuthentication(mockAuth);
+
+ when(authenticationManager.authenticate(any(Authentication.class)))
+ .thenThrow(new BadCredentialsException("Invalid TOTP"));
+
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getParameter("totp")).thenReturn("wrong-totp");
+
+ assertThrows(BadCredentialsException.class,
+ () -> multiFactorVerificationFilter.attemptAuthentication(request, response));
+ }
+
+ @Test
+ public void testAuthenticationFailureWhenTotpIsNull() {
+ Authentication mockAuth = mock(ExtendedAuthenticationToken.class);
+ when(mockAuth.getName()).thenReturn("username");
+
+ SecurityContextHolder.getContext().setAuthentication(mockAuth);
+
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getParameter("totp")).thenReturn(null);
+
+ assertThrows(ProviderNotFoundException.class,
+ () -> multiFactorVerificationFilter.attemptAuthentication(request, response));
+ }
+}
+
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java
new file mode 100644
index 000000000..ad7cd667c
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/multi_factor_authentication/authenticator_app/AuthenticatorAppSettingsControllerTests.java
@@ -0,0 +1,125 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package it.infn.mw.iam.test.multi_factor_authentication.authenticator_app;
+
+import static it.infn.mw.iam.api.account.multi_factor_authentication.authenticator_app.AuthenticatorAppSettingsController.DISABLE_URL_FOR_ACCOUNT_ID;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.Mockito.when;
+import static org.hamcrest.Matchers.hasSize;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.hamcrest.Matchers.equalTo;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+import it.infn.mw.iam.IamLoginService;
+import it.infn.mw.iam.core.IamNotificationType;
+import it.infn.mw.iam.persistence.model.IamEmailNotification;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.IamEmailNotificationRepository;
+import it.infn.mw.iam.persistence.repository.IamTotpMfaRepository;
+import it.infn.mw.iam.test.core.CoreControllerTestSupport;
+import it.infn.mw.iam.test.multi_factor_authentication.MultiFactorTestSupport;
+import it.infn.mw.iam.test.notification.NotificationTestConfig;
+import it.infn.mw.iam.test.util.WithAnonymousUser;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+import it.infn.mw.iam.test.util.notification.MockNotificationDelivery;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = { IamLoginService.class, CoreControllerTestSupport.class,
+ NotificationTestConfig.class }, webEnvironment = WebEnvironment.MOCK)
+@IamMockMvcIntegrationTest
+@TestPropertySource(properties = { "notification.disable=false" })
+public class AuthenticatorAppSettingsControllerTests extends MultiFactorTestSupport {
+ private MockMvc mvc;
+ @Autowired
+ private WebApplicationContext context;
+ @Autowired
+ private MockNotificationDelivery notificationDelivery;
+ @Autowired
+ private IamEmailNotificationRepository notificationRepo;
+ @MockBean
+ private IamAccountRepository accountRepository;
+ @MockBean
+ private IamTotpMfaRepository totpMfaRepository;
+
+ @Before
+ public void setup() {
+ when(accountRepository.findByUuid(TOTP_UUID)).thenReturn(Optional.of(TOTP_MFA_ACCOUNT));
+ when(totpMfaRepository.findByAccount(TOTP_MFA_ACCOUNT)).thenReturn(Optional.of(TOTP_MFA));
+
+ mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).alwaysDo(log()).build();
+ }
+
+ @After
+ public void tearDown() {
+ notificationDelivery.clearDeliveredNotifications();
+ }
+
+ @Test
+ @WithAnonymousUser
+ public void testDisableAuthenticatorAppNoAuthenticationFails() throws Exception {
+ mvc.perform(delete(DISABLE_URL_FOR_ACCOUNT_ID, TOTP_UUID))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = "ADMIN")
+ public void testDisableAuthenticatorAppWorksForAdmin() throws Exception {
+ mvc.perform(delete(DISABLE_URL_FOR_ACCOUNT_ID, TOTP_UUID))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = "ADMIN")
+ public void testConfirmationEmailSentOnMfaDisable() throws Exception {
+ mvc.perform(delete(DISABLE_URL_FOR_ACCOUNT_ID, TOTP_UUID))
+ .andExpect(status().isOk());
+
+ List notifications = notificationRepo
+ .findByNotificationType(IamNotificationType.MFA_DISABLE);
+
+ assertEquals(1, notifications.size());
+ assertEquals("[indigo-dc IAM] Multi-factor authentication (MFA) disabled", notifications.get(0).getSubject());
+
+ notificationDelivery.sendPendingNotifications();
+
+ assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(1));
+ IamEmailNotification message = notificationDelivery.getDeliveredNotifications().get(0);
+ assertThat(message.getSubject(), equalTo("[indigo-dc IAM] Multi-factor authentication (MFA) disabled"));
+ }
+
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowNotificationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowNotificationTests.java
index 02bda33ea..4ccc1dd88 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowNotificationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/notification/RegistrationFlowNotificationTests.java
@@ -16,17 +16,21 @@
package it.infn.mw.iam.test.notification;
import static it.infn.mw.iam.test.util.AuthenticationUtils.adminAuthentication;
+import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.MatcherAssert.assertThat;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
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.head;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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 org.junit.After;
@@ -50,6 +54,7 @@
import it.infn.mw.iam.IamLoginService;
import it.infn.mw.iam.notification.NotificationProperties;
import it.infn.mw.iam.persistence.model.IamEmailNotification;
+import it.infn.mw.iam.persistence.repository.IamRegistrationRequestRepository;
import it.infn.mw.iam.registration.PersistentUUIDTokenGenerator;
import it.infn.mw.iam.registration.RegistrationRequestDto;
import it.infn.mw.iam.test.core.CoreControllerTestSupport;
@@ -96,16 +101,19 @@ public class RegistrationFlowNotificationTests {
@Autowired
private ObjectMapper mapper;
+ @Autowired
+ private IamRegistrationRequestRepository requestRepository;
+
private MockMvc mvc;
@Before
- public void setUp() throws InterruptedException {
+ public void setUp() {
mvc =
MockMvcBuilders.webAppContextSetup(context).alwaysDo(log()).apply(springSecurity()).build();
}
@After
- public void tearDown() throws InterruptedException {
+ public void tearDown() {
mockOAuth2Filter.cleanupSecurityContext();
notificationDelivery.clearDeliveredNotifications();
}
@@ -141,6 +149,14 @@ public void testApproveFlowNotifications() throws Exception {
.getResponse()
.getContentAsString();
+ String confirmationKey = generator.getLastToken();
+
+ assertThat(requestRepository.findByAccountConfirmationKey(confirmationKey)
+ .get()
+ .getAccount()
+ .getUserInfo()
+ .getEmail(), is("approve_flow@example.org"));
+
request = mapper.readValue(responseJson, RegistrationRequestDto.class);
notificationDelivery.sendPendingNotifications();
@@ -153,11 +169,21 @@ public void testApproveFlowNotifications() throws Exception {
notificationDelivery.clearDeliveredNotifications();
- String confirmationKey = generator.getLastToken();
+ mvc.perform(head("/registration/verify/{token}", confirmationKey)).andExpect(status().isOk());
- mvc.perform(get("/registration/confirm/{token}", confirmationKey).contentType(APPLICATION_JSON))
- .andExpect(status().isOk());
+ mvc.perform(get("/registration/verify/wrongtoken")).andExpect(status().isOk());
+ mvc
+ .perform(post("/registration/verify").content("token=wrongtoken")
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationFailure"));
+
+ mvc
+ .perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
notificationDelivery.sendPendingNotifications();
@@ -171,7 +197,6 @@ public void testApproveFlowNotifications() throws Exception {
assertThat(message.getReceivers().get(0).getEmailAddress(),
equalTo(properties.getAdminAddress()));
-
notificationDelivery.clearDeliveredNotifications();
mvc.perform(post("/registration/approve/{uuid}", request.getUuid())
@@ -191,24 +216,12 @@ public void testApproveFlowNotifications() throws Exception {
@Test
public void testRejectFlowNoMotivationNotifications() throws Exception {
- String username = "reject_flow";
-
- RegistrationRequestDto request = new RegistrationRequestDto();
- request.setGivenname("Reject flow");
- request.setFamilyname("Test");
- request.setEmail("reject_flow@example.org");
- request.setUsername(username);
- request.setNotes("Some short notes...");
- String responseJson = mvc
- .perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
- .content(mapper.writeValueAsString(request)))
- .andExpect(MockMvcResultMatchers.status().isOk())
- .andReturn()
- .getResponse()
- .getContentAsString();
+ RegistrationRequestDto request = createRegistrationRequest(getRequestForRejectFlow());
- request = mapper.readValue(responseJson, RegistrationRequestDto.class);
+ mvc.perform(post("/registration/reject/{uuid}", request.getUuid())
+ .with(authentication(adminAuthentication()))
+ .contentType(APPLICATION_JSON)).andExpect(status().isOk());
notificationDelivery.sendPendingNotifications();
@@ -216,58 +229,66 @@ public void testRejectFlowNoMotivationNotifications() throws Exception {
IamEmailNotification message = notificationDelivery.getDeliveredNotifications().get(0);
- assertThat(message.getSubject(), equalTo(formatSubject("confirmation")));
-
- notificationDelivery.clearDeliveredNotifications();
+ assertThat(message.getSubject(), equalTo(formatSubject("rejected")));
+ assertThat(message.getBody(),
+ not(containsString("The administrator has provided the following motivation")));
- String confirmationKey = generator.getLastToken();
+ }
- mvc.perform(get("/registration/confirm/{token}", confirmationKey).contentType(APPLICATION_JSON))
- .andExpect(status().isOk());
+ @Test
+ public void testRejectFlowNoNotificationSent() throws Exception {
+ RegistrationRequestDto request = createRegistrationRequest(getRequestForRejectFlow());
+ mvc.perform(post("/registration/reject/{uuid}", request.getUuid())
+ .param("motivation", "Lack of motivation")
+ .param("doNotSendEmail", "true")
+ .with(authentication(adminAuthentication()))
+ .contentType(APPLICATION_JSON)).andExpect(status().isOk());
notificationDelivery.sendPendingNotifications();
- assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(1));
-
- message = notificationDelivery.getDeliveredNotifications().get(0);
-
- assertThat(message.getSubject(), equalTo(formatSubject("adminHandleRequest")));
+ assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(0));
- assertThat(message.getReceivers(), hasSize(1));
- assertThat(message.getReceivers().get(0).getEmailAddress(),
- equalTo(properties.getAdminAddress()));
+ }
+ @Test
+ public void testRejectFlowMotivationNotifications() throws Exception {
- notificationDelivery.clearDeliveredNotifications();
+ RegistrationRequestDto request = createRegistrationRequest(getRequestForRejectFlow());
- mvc.perform(post("/registration/reject/{uuid}", request.getUuid())
- .with(authentication(adminAuthentication()))
- .contentType(APPLICATION_JSON)).andExpect(status().isOk());
+ mvc.perform(
+ post("/registration/reject/{uuid}", request.getUuid()).param("motivation", "We hate you")
+ .with(authentication(adminAuthentication()))
+ .contentType(APPLICATION_JSON))
+ .andExpect(status().isOk());
notificationDelivery.sendPendingNotifications();
assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(1));
- message = notificationDelivery.getDeliveredNotifications().get(0);
+ IamEmailNotification message = notificationDelivery.getDeliveredNotifications().get(0);
assertThat(message.getSubject(), equalTo(formatSubject("rejected")));
assertThat(message.getBody(),
- not(containsString("The administrator has provided the following motivation")));
+ containsString("The administrator has provided the following motivation"));
+ assertThat(message.getBody(), containsString("We hate you"));
}
- @Test
- public void testRejectFlowMotivationNotifications() throws Exception {
- String username = "reject_flow";
-
+ private RegistrationRequestDto getRequestForRejectFlow() {
RegistrationRequestDto request = new RegistrationRequestDto();
request.setGivenname("Reject flow");
request.setFamilyname("Test");
request.setEmail("reject_flow@example.org");
- request.setUsername(username);
+ request.setUsername("reject_flow");
request.setNotes("Some short notes...");
+ return request;
+ }
+
+ private RegistrationRequestDto createRegistrationRequest(RegistrationRequestDto request)
+ throws Exception {
+
String responseJson = mvc
.perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request)))
@@ -290,9 +311,11 @@ public void testRejectFlowMotivationNotifications() throws Exception {
String confirmationKey = generator.getLastToken();
- mvc.perform(get("/registration/confirm/{token}", confirmationKey).contentType(APPLICATION_JSON))
- .andExpect(status().isOk());
-
+ mvc
+ .perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
notificationDelivery.sendPendingNotifications();
@@ -308,24 +331,7 @@ public void testRejectFlowMotivationNotifications() throws Exception {
notificationDelivery.clearDeliveredNotifications();
-
- mvc.perform(
- post("/registration/reject/{uuid}", request.getUuid()).param("motivation", "We hate you")
- .with(authentication(adminAuthentication()))
- .contentType(APPLICATION_JSON))
- .andExpect(status().isOk());
-
- notificationDelivery.sendPendingNotifications();
-
- assertThat(notificationDelivery.getDeliveredNotifications(), hasSize(1));
-
- message = notificationDelivery.getDeliveredNotifications().get(0);
-
- assertThat(message.getSubject(), equalTo(formatSubject("rejected")));
- assertThat(message.getBody(),
- containsString("The administrator has provided the following motivation"));
- assertThat(message.getBody(), containsString("We hate you"));
-
+ return request;
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/ClientRegistrationAuthzTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/ClientRegistrationAuthzTests.java
new file mode 100644
index 000000000..d61424947
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/ClientRegistrationAuthzTests.java
@@ -0,0 +1,113 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.oauth;
+
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mitre.oauth2.model.ClientDetailsEntity;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import it.infn.mw.iam.api.common.client.RegisteredClientDTO;
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.repository.IamAccountRepository;
+import it.infn.mw.iam.persistence.repository.client.IamAccountClientRepository;
+import it.infn.mw.iam.persistence.repository.client.IamClientRepository;
+import it.infn.mw.iam.test.oauth.client_registration.ClientRegistrationTestSupport;
+import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+
+
+@RunWith(SpringRunner.class)
+@IamMockMvcIntegrationTest
+@TestPropertySource(properties = {"client-registration.allow-for=REGISTERED_USERS"})
+public class ClientRegistrationAuthzTests extends ClientRegistrationTestSupport {
+
+ @Autowired
+ private MockMvc mvc;
+
+ @Autowired
+ private IamAccountClientRepository accountClientRepo;
+
+ @Autowired
+ private IamClientRepository clientRepo;
+
+ @Autowired
+ private ObjectMapper mapper;
+
+ @Autowired
+ private IamAccountRepository accountRepo;
+
+ @Test
+ public void testClientRegistrationRequiresAuthenticatedUser() throws Exception {
+
+ String jsonInString = ClientJsonStringBuilder.builder().scopes("test").build();
+
+ mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString))
+ .andExpect(status().isForbidden());
+ }
+
+ @WithMockUser(username = "test", roles = "USER")
+ @Test
+ public void testClientRegistrationWorksForAuthenticatedUser() throws Exception {
+
+ IamAccount testAccount = accountRepo.findByUsername("test").orElseThrow();
+
+ String jsonInString = ClientJsonStringBuilder.builder().scopes("test").build();
+
+ String responseJson =
+ mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString))
+ .andExpect(status().isCreated())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ RegisteredClientDTO response = mapper.readValue(responseJson, RegisteredClientDTO.class);
+
+ ClientDetailsEntity client = clientRepo.findByClientId(response.getClientId()).orElseThrow();
+ accountClientRepo.findByAccountAndClient(testAccount, client).orElseThrow();
+ }
+
+ @WithMockUser(username = "admin", roles = {"ADMIN", "USER"})
+ @Test
+ public void testClientRegistrationWorksForAdminUser() throws Exception {
+
+ IamAccount adminAccount = accountRepo.findByUsername("admin").orElseThrow();
+
+ String jsonInString = ClientJsonStringBuilder.builder().scopes("test").build();
+
+ String responseJson =
+ mvc.perform(post(REGISTER_ENDPOINT).contentType(APPLICATION_JSON).content(jsonInString))
+ .andExpect(status().isCreated())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+
+ RegisteredClientDTO response = mapper.readValue(responseJson, RegisteredClientDTO.class);
+
+ ClientDetailsEntity client = clientRepo.findByClientId(response.getClientId()).orElseThrow();
+ accountClientRepo.findByAccountAndClient(adminAccount, client).orElseThrow();
+ }
+
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java
index b654ee0b0..cddf847a8 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/authzcode/AuthorizationCodeIntegrationTests.java
@@ -33,7 +33,6 @@
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.util.UriComponentsBuilder;
-import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jwt.JWT;
import com.nimbusds.jwt.JWTParser;
@@ -88,7 +87,7 @@ public void setup() {
@Test
public void testAuthzCodeAudienceSupport()
- throws JsonProcessingException, IOException, ParseException {
+ throws IOException, ParseException {
String[] audienceKeys = {"aud", "audience"};
@@ -112,7 +111,7 @@ public void testAuthzCodeAudienceSupport()
// @formatter:on
// @formatter:off
- ValidatableResponse resp2 = RestAssured.given()
+ RestAssured.given()
.formParam("username", "test")
.formParam("password", "password")
.formParam("submit", "Login")
@@ -126,7 +125,7 @@ public void testAuthzCodeAudienceSupport()
// @formatter:off
RestAssured.given()
- .cookie(resp2.extract().detailedCookie("JSESSIONID"))
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
.queryParam("response_type", RESPONSE_TYPE_CODE)
.queryParam("client_id", TEST_CLIENT_ID)
.queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
@@ -143,8 +142,8 @@ public void testAuthzCodeAudienceSupport()
// @formatter:on
// @formatter:off
- ValidatableResponse resp4 = RestAssured.given()
- .cookie(resp2.extract().detailedCookie("JSESSIONID"))
+ ValidatableResponse resp2 = RestAssured.given()
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
.formParam("user_oauth_approval", "true")
.formParam("authorize", "Authorize")
.formParam("scope_openid", "openid")
@@ -157,14 +156,14 @@ public void testAuthzCodeAudienceSupport()
.statusCode(HttpStatus.SEE_OTHER.value());
// @formatter:on
- String authzCode = UriComponentsBuilder.fromHttpUrl(resp4.extract().header("Location"))
+ String authzCode = UriComponentsBuilder.fromHttpUrl(resp2.extract().header("Location"))
.build()
.getQueryParams()
.get("code")
.get(0);
// @formatter:off
- ValidatableResponse resp5= RestAssured.given()
+ ValidatableResponse resp3 = RestAssured.given()
.formParam("grant_type", "authorization_code")
.formParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
.formParam("code", authzCode)
@@ -179,9 +178,9 @@ public void testAuthzCodeAudienceSupport()
// @formatter:on
String accessToken =
- mapper.readTree(resp5.extract().body().asString()).get("access_token").asText();
+ mapper.readTree(resp3.extract().body().asString()).get("access_token").asText();
- String idToken = mapper.readTree(resp5.extract().body().asString()).get("id_token").asText();
+ String idToken = mapper.readTree(resp3.extract().body().asString()).get("id_token").asText();
JWT atJwt = JWTParser.parse(accessToken);
JWT itJwt = JWTParser.parse(idToken);
@@ -197,7 +196,7 @@ public void testAuthzCodeAudienceSupport()
@Test
public void testRefreshTokenAfterAuthzCodeWorks()
- throws JsonProcessingException, IOException, ParseException {
+ throws IOException {
// @formatter:off
ValidatableResponse resp1 = RestAssured.given()
@@ -216,7 +215,7 @@ public void testRefreshTokenAfterAuthzCodeWorks()
// @formatter:on
// @formatter:off
- ValidatableResponse resp2 = RestAssured.given()
+ RestAssured.given()
.formParam("username", "test")
.formParam("password", "password")
.formParam("submit", "Login")
@@ -230,7 +229,7 @@ public void testRefreshTokenAfterAuthzCodeWorks()
// @formatter:off
RestAssured.given()
- .cookie(resp2.extract().detailedCookie("JSESSIONID"))
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
.queryParam("response_type", RESPONSE_TYPE_CODE)
.queryParam("client_id", TEST_CLIENT_ID)
.queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
@@ -246,8 +245,8 @@ public void testRefreshTokenAfterAuthzCodeWorks()
// @formatter:on
// @formatter:off
- ValidatableResponse resp4 = RestAssured.given()
- .cookie(resp2.extract().detailedCookie("JSESSIONID"))
+ ValidatableResponse resp2 = RestAssured.given()
+ .cookie(resp1.extract().detailedCookie("JSESSIONID"))
.formParam("user_oauth_approval", "true")
.formParam("authorize", "Authorize")
.formParam("scope_openid", "openid")
@@ -265,14 +264,14 @@ public void testRefreshTokenAfterAuthzCodeWorks()
.statusCode(HttpStatus.SEE_OTHER.value());
// @formatter:on
- String authzCode = UriComponentsBuilder.fromHttpUrl(resp4.extract().header("Location"))
+ String authzCode = UriComponentsBuilder.fromHttpUrl(resp2.extract().header("Location"))
.build()
.getQueryParams()
.get("code")
.get(0);
// @formatter:off
- ValidatableResponse resp5= RestAssured.given()
+ ValidatableResponse resp3 = RestAssured.given()
.formParam("grant_type", "authorization_code")
.formParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
.formParam("code", authzCode)
@@ -287,10 +286,10 @@ public void testRefreshTokenAfterAuthzCodeWorks()
// @formatter:on
String refreshToken =
- mapper.readTree(resp5.extract().body().asString()).get("refresh_token").asText();
+ mapper.readTree(resp3.extract().body().asString()).get("refresh_token").asText();
// @formatter:off
- ValidatableResponse resp6= RestAssured.given()
+ ValidatableResponse resp4 = RestAssured.given()
.formParam("grant_type", "refresh_token")
.formParam("refresh_token", refreshToken)
.formParam("scope", "openid")
@@ -304,7 +303,7 @@ public void testRefreshTokenAfterAuthzCodeWorks()
// @formatter:on
String refreshedToken =
- mapper.readTree(resp6.extract().body().asString()).get("access_token").asText();
+ mapper.readTree(resp4.extract().body().asString()).get("access_token").asText();
// @formatter:off
RestAssured.given()
@@ -444,28 +443,28 @@ public void testRefreshTokenAfterAuthzCodeWorks()
.when()
.get("/iam/group/c617d586-54e6-411d-8e38-649677980001/attributes")
.then()
- .statusCode(HttpStatus.OK.value());
+ .statusCode(HttpStatus.FORBIDDEN.value());
RestAssured.given()
.header("Authorization", "Bearer " + refreshedToken)
.when()
.get("/iam/me/authorities")
.then()
- .statusCode(HttpStatus.OK.value());
+ .statusCode(HttpStatus.FORBIDDEN.value());
RestAssured.given()
.header("Authorization", "Bearer " + refreshedToken)
.when()
.get("/iam/api/clients")
.then()
- .statusCode(HttpStatus.OK.value());
+ .statusCode(HttpStatus.FORBIDDEN.value());
RestAssured.given()
.header("Authorization", "Bearer " + refreshedToken)
.when()
.get("/iam/scope_policies")
.then()
- .statusCode(HttpStatus.OK.value());
+ .statusCode(HttpStatus.FORBIDDEN.value());
// @formatter:on
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java
index e41ae79d0..1f5ca5190 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/devicecode/DeviceCodeTests.java
@@ -63,7 +63,6 @@
import it.infn.mw.iam.test.oauth.client_registration.ClientRegistrationTestSupport.ClientJsonStringBuilder;
import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
-
@RunWith(SpringRunner.class)
@IamMockMvcIntegrationTest
@SpringBootTest(classes = {IamLoginService.class}, webEnvironment = WebEnvironment.MOCK)
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java
index d665b6f01..94aaeddb6 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/jwk/JWKEndpointTests.java
@@ -55,4 +55,5 @@ public void jwkEndpointReturnsKeyMaterial() throws Exception {
// @formatter:on
}
+
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java
index cc17a7806..57999bceb 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/AarcClaimValueHelperTests.java
@@ -86,7 +86,6 @@ public void testGroupUrnEncode() {
g.setName("test");
groupService.createGroup(g);
-
when(userInfo.getGroups()).thenReturn(Sets.newHashSet(g));
Set urns = helper.resolveGroups(userInfo);
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java
index 81e6db6ca..a9dde62d8 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/ScopesFilterTests.java
@@ -18,10 +18,9 @@
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.empty;
-import static org.junit.Assert.assertThat;
-import java.io.IOException;
import java.util.List;
import javax.persistence.EntityManager;
@@ -38,9 +37,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
-import com.fasterxml.jackson.core.JsonProcessingException;
-
-import freemarker.core.ParseException;
import io.restassured.RestAssured;
import io.restassured.response.ValidatableResponse;
import it.infn.mw.iam.IamLoginService;
@@ -63,6 +59,7 @@ public class ScopesFilterTests extends ScopePolicyTestUtils {
public static final String RESPONSE_TYPE_CODE = "code";
public static final String EMAIL = "email";
public static final String SCOPE = "openid email";
+ public static final String SCOPE_ADMIN = "iam:admin.write";
public static final String LOCALHOST_URL_TEMPLATE = "http://localhost:%d";
public static final String TEST_CLIENT_REDIRECT_URI =
"https://iam.local.io/iam-test-client/openid_connect_login";
@@ -103,8 +100,7 @@ public void setup() {
}
@Test
- public void testConsentPageReturnsFilteredScopes()
- throws JsonProcessingException, IOException, ParseException {
+ public void testConsentPageReturnsFilteredScopes() {
IamAccount testAccount = findTestAccount();
@@ -140,7 +136,7 @@ public void testConsentPageReturnsFilteredScopes()
// @formatter:on
// @formatter:off
- ValidatableResponse loginResponse = RestAssured.given()
+ RestAssured.given()
.cookie(authzResponse.extract().detailedCookie(SESSION))
.formParam("username", "test")
.formParam("password", "password")
@@ -155,7 +151,7 @@ public void testConsentPageReturnsFilteredScopes()
// @formatter:off
String responseBody = RestAssured.given()
- .cookie(loginResponse.extract().detailedCookie(SESSION))
+ .cookie(authzResponse.extract().detailedCookie(SESSION))
.queryParam("response_type", RESPONSE_TYPE_CODE)
.queryParam("client_id", TEST_CLIENT_ID)
.queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
@@ -178,4 +174,114 @@ public void testConsentPageReturnsFilteredScopes()
}
+ @Test
+ public void testConsentPageDoesNotReturnAdminScopeToRegularUser() {
+
+ // @formatter:off
+ ValidatableResponse authzResponse = RestAssured.given()
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", "admin-client-rw")
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE_ADMIN)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.FOUND.value());
+ // @formatter:on
+
+ // @formatter:off
+ RestAssured.given()
+ .cookie(authzResponse.extract().detailedCookie(SESSION))
+ .formParam("username", "test")
+ .formParam("password", "password")
+ .formParam("submit", "Login")
+ .redirects().follow(false)
+ .when()
+ .post(loginUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.FOUND.value());
+ // @formatter:on
+
+ // @formatter:off
+ String responseBody = RestAssured.given()
+ .cookie(authzResponse.extract().detailedCookie(SESSION))
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", "admin-client-rw")
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE_ADMIN)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.OK.value())
+ .extract().body().asString();
+ // @formatter:on
+
+ assertThat(responseBody, not(containsString("iam:admin.write")));
+
+ }
+
+ @Test
+ public void testConsentPageReturnsAdminScopeToAdmins() {
+
+ // @formatter:off
+ ValidatableResponse authzResponse = RestAssured.given()
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", TEST_CLIENT_ID)
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE_ADMIN)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.FOUND.value());
+ // @formatter:on
+
+ // @formatter:off
+ RestAssured.given()
+ .cookie(authzResponse.extract().detailedCookie(SESSION))
+ .formParam("username", "admin")
+ .formParam("password", "password")
+ .formParam("submit", "Login")
+ .redirects().follow(false)
+ .when()
+ .post(loginUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.FOUND.value());
+ // @formatter:on
+
+ // @formatter:off
+ String responseBody = RestAssured.given()
+ .cookie(authzResponse.extract().detailedCookie(SESSION))
+ .queryParam("response_type", RESPONSE_TYPE_CODE)
+ .queryParam("client_id", TEST_CLIENT_ID)
+ .queryParam("redirect_uri", TEST_CLIENT_REDIRECT_URI)
+ .queryParam("scope", SCOPE_ADMIN)
+ .queryParam("nonce", "1")
+ .queryParam("state", "1")
+ .redirects().follow(false)
+ .when()
+ .get(authorizeUrl)
+ .then()
+ .log().all()
+ .statusCode(HttpStatus.OK.value())
+ .extract().body().asString();
+ // @formatter:on
+
+ assertThat(responseBody, containsString("iam:admin.write"));
+
+ }
+
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java
index 23395bf5d..06d75b4c1 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyApiIntegrationTests.java
@@ -53,6 +53,7 @@
import it.infn.mw.iam.persistence.model.IamGroup;
import it.infn.mw.iam.persistence.model.IamScopePolicy;
import it.infn.mw.iam.persistence.model.PolicyRule;
+import it.infn.mw.iam.persistence.model.IamScopePolicy.MatchingPolicy;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
import it.infn.mw.iam.persistence.repository.IamGroupRepository;
import it.infn.mw.iam.persistence.repository.IamScopePolicyRepository;
@@ -547,6 +548,31 @@ public void testDefaultPolicyUpdate() throws Exception {
.andExpect(jsonPath("$.scopes").doesNotExist());
}
+
+ @Test
+ @WithMockOAuthUser(user = "admin", authorities = {"ROLE_USER", "ROLE_ADMIN"}, scopes = {"iam:admin.read", "iam:admin.write"})
+ public void testDefaultPolicyUpdateUpdatingMatchingPolicy() throws Exception {
+ final String description = "DENY ALL!";
+
+ ScopePolicyDTO sp = new ScopePolicyDTO();
+ sp.setDescription(description);
+ sp.setRule(PolicyRule.DENY.name());
+ sp.setMatchingPolicy(MatchingPolicy.PATH.name());
+ sp.setScopes(Sets.newHashSet(SCIM_READ, SCIM_WRITE));
+ sp.setId(1L);
+
+ String serializedSp = mapper.writeValueAsString(sp);
+ mvc.perform(put("/iam/scope_policies/1").content(serializedSp).contentType(APPLICATION_JSON))
+ .andExpect(status().isNoContent());
+
+ mvc.perform(get("/iam/scope_policies/1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id", equalTo(1)))
+ .andExpect(jsonPath("$.rule", equalTo("DENY")))
+ .andExpect(jsonPath("$.description", equalTo(description)))
+ .andExpect(jsonPath("$.matchingPolicy", equalTo(MatchingPolicy.PATH.name())));
+
+ }
@Test
@WithMockOAuthUser(user = "admin", authorities = {"ROLE_USER", "ROLE_ADMIN"}, scopes = "iam:admin.write")
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java
index dd6882b24..b84f9a495 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/scope/pdp/ScopePolicyFilteringIntegrationTests.java
@@ -164,7 +164,12 @@ public void authzCodeFlowScopeFilteringByAccountWorks() throws Exception {
.getRequest()
.getSession();
- session = (MockHttpSession) mvc.perform(get("/authorize").session(session))
+ mvc.perform(get("/authorize").session(session)
+ .param("scope", "openid profile read-tasks")
+ .param("response_type", "code")
+ .param("client_id", clientId)
+ .param("redirect_uri", "https://iam.local.io/iam-test-client/openid_connect_login")
+ .param("state", "1234567"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/oauth/confirm_access"))
.andExpect(model().attribute("scope", equalTo("openid profile")))
@@ -207,7 +212,12 @@ public void matchingPolicyFilteringWorks() throws Exception {
.getRequest()
.getSession();
- session = (MockHttpSession) mvc.perform(get("/authorize").session(session))
+ mvc.perform(get("/authorize").session(session)
+ .param("scope", "openid profile read:/ read:/that/thing write:/")
+ .param("response_type", "code")
+ .param("client_id", clientId)
+ .param("redirect_uri", "https://iam.local.io/iam-test-client/openid_connect_login")
+ .param("state", "1234567"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/oauth/confirm_access"))
.andExpect(model().attribute("scope", equalTo("openid profile")))
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/ExternalAuthenticationRegistrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/ExternalAuthenticationRegistrationTests.java
index 721ca17ee..690b968b5 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/ExternalAuthenticationRegistrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/ExternalAuthenticationRegistrationTests.java
@@ -17,11 +17,12 @@
import static it.infn.mw.iam.test.ext_authn.saml.SamlAuthenticationTestSupport.DEFAULT_IDP_ID;
import static org.hamcrest.CoreMatchers.hasItem;
+import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.Assert.assertNotNull;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Test;
@@ -34,7 +35,6 @@
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
-import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import it.infn.mw.iam.IamLoginService;
@@ -68,7 +68,7 @@ public class ExternalAuthenticationRegistrationTests {
@Test
@WithMockOIDCUser
- public void testExtAuthOIDC() throws JsonProcessingException, Exception {
+ public void testExtAuthOIDC() throws Exception {
String username = "test-oidc-subject";
@@ -91,7 +91,10 @@ public void testExtAuthOIDC() throws JsonProcessingException, Exception {
request = objectMapper.readValue(requestBytes, RegistrationRequestDto.class);
String token = generator.getLastToken();
- mvc.perform(get("/registration/confirm/{token}", token)).andExpect(status().isOk());
+ mvc.perform(post("/registration/verify").content("token=" + token)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
mvc
.perform(post("/registration/approve/{uuid}", request.getUuid())
@@ -113,7 +116,7 @@ public void testExtAuthOIDC() throws JsonProcessingException, Exception {
@Test
@WithMockSAMLUser
- public void testExtAuthSAML() throws JsonProcessingException, Exception {
+ public void testExtAuthSAML() throws Exception {
String username = "test-saml-user";
@@ -136,7 +139,10 @@ public void testExtAuthSAML() throws JsonProcessingException, Exception {
request = objectMapper.readValue(requestBytes, RegistrationRequestDto.class);
String token = generator.getLastToken();
- mvc.perform(get("/registration/confirm/{token}", token)).andExpect(status().isOk());
+ mvc.perform(post("/registration/verify").content("token=" + token)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
mvc
.perform(post("/registration/approve/{uuid}", request.getUuid())
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/OidcExtAuthRegistrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/OidcExtAuthRegistrationTests.java
index 77a4e629e..f7ed60a66 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/OidcExtAuthRegistrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/OidcExtAuthRegistrationTests.java
@@ -23,8 +23,10 @@
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.assertTrue;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -102,7 +104,7 @@ public void externalOidcRegistrationCreatesDisabledAccount() throws Exception {
request.setUsername(username);
request.setNotes("Some short notes...");
- byte[] requestBytes = mvc
+ mvc
.perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(request)))
.andExpect(status().isOk())
@@ -110,7 +112,6 @@ public void externalOidcRegistrationCreatesDisabledAccount() throws Exception {
.getResponse()
.getContentAsByteArray();
- request = objectMapper.readValue(requestBytes, RegistrationRequestDto.class);
String token = generator.getLastToken();
// If the user tries to authenticate with his external account, he's redirected to the
@@ -149,7 +150,10 @@ public void externalOidcRegistrationCreatesDisabledAccount() throws Exception {
startsWith("Your registration request to indigo-dc was submitted successfully"));
// the same happens after having confirmed the request
- mvc.perform(get("/registration/confirm/{token}", token)).andExpect(status().isOk());
+ mvc.perform(post("/registration/verify").content("token=" + token)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
session = (MockHttpSession) mvc
.perform(get("/openid_connect_login")
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationPrivilegedTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationPrivilegedTests.java
index 83aa6cb8f..2b0e3bc58 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationPrivilegedTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationPrivilegedTests.java
@@ -17,7 +17,6 @@
import static it.infn.mw.iam.api.scim.model.ScimConstants.SCIM_CONTENT_TYPE;
import static it.infn.mw.iam.core.IamRegistrationRequestStatus.APPROVED;
-import static it.infn.mw.iam.core.IamRegistrationRequestStatus.CONFIRMED;
import static it.infn.mw.iam.core.IamRegistrationRequestStatus.NEW;
import static it.infn.mw.iam.core.IamRegistrationRequestStatus.REJECTED;
import static org.hamcrest.CoreMatchers.nullValue;
@@ -25,9 +24,11 @@
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertNotNull;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.function.Supplier;
@@ -83,7 +84,6 @@ public class RegistrationPrivilegedTests {
@Before
public void setup() {
- requestRepo.deleteAll();
mockOAuth2Filter.cleanupSecurityContext();
}
@@ -122,11 +122,11 @@ private RegistrationRequestDto createRegistrationRequest(String username) throws
}
private void confirmRegistrationRequest(String confirmationKey) throws Exception {
- // @formatter:off
- mvc.perform(get("/registration/confirm/{token}", confirmationKey))
+ mvc
+ .perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.status", equalTo(CONFIRMED.name())));
- // @formatter:on
+ .andExpect(model().attributeExists("verificationSuccess"));
}
protected RegistrationRequestDto approveRequest(String uuid) throws Exception {
@@ -279,8 +279,10 @@ public void testConfirmAfterApprovation() throws Exception {
approveRequest(reg.getUuid());
// @formatter:off
- mvc.perform(get("/registration/confirm/{token}", confirmationKey))
- .andExpect(status().isNotFound());
+ mvc.perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationFailure"));
// @formatter:on
}
@@ -294,8 +296,10 @@ public void confirmAlreadyConfirmedRequest() throws Exception {
String confirmationKey = generator.getLastToken();
approveRequest(reg.getUuid());
- mvc.perform(get("/registration/confirm/{token}", confirmationKey))
- .andExpect(status().isNotFound());
+ mvc.perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationFailure"));
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java
index 021ff78af..14b3c028e 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/RegistrationUnprivilegedTests.java
@@ -16,17 +16,23 @@
package it.infn.mw.iam.test.registration;
import static it.infn.mw.iam.core.IamRegistrationRequestStatus.APPROVED;
-import static it.infn.mw.iam.core.IamRegistrationRequestStatus.CONFIRMED;
+import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
-import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Before;
@@ -47,6 +53,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import it.infn.mw.iam.IamLoginService;
+import it.infn.mw.iam.config.IamProperties.RegistrationFieldProperties;
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.model.IamAup;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
@@ -135,11 +142,8 @@ public void testConfirmRequest() throws Exception {
@Test
public void testListRequestsUnauthorized() throws Exception {
- // @formatter:off
- mvc.perform(get("/registration/list")
- .with(authentication(anonymousAuthenticationToken())))
+ mvc.perform(get("/registration/list").with(authentication(anonymousAuthenticationToken())))
.andExpect(status().isUnauthorized());
- // @formatter:on
}
@Test
@@ -148,10 +152,11 @@ public void testConfirmRequestFailureWithWrongToken() throws Exception {
createRegistrationRequest("test_confirm_fail");
String badToken = "abcdefghilmnopqrstuvz";
- // @formatter:off
- mvc.perform(get("/registration/confirm/{token}", badToken))
- .andExpect(status().isNotFound());
- // @formatter:on
+ mvc
+ .perform(post("/registration/verify").content("token=" + badToken)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationFailure"));
}
@Test
@@ -163,33 +168,28 @@ public void testApproveRequestUnauthorized() throws Exception {
String token = generator.getLastToken();
assertNotNull(token);
+ mvc.perform(head("/registration/verify/" + token)).andExpect(status().isOk());
+
confirmRegistrationRequest(token);
- // @formatter:off
mvc.perform(post("/registration/{uuid}/{decision}", reg.getUuid(), APPROVED.name())
- .with(authentication(anonymousAuthenticationToken())))
- .andExpect(status().isUnauthorized());
- // @formatter:on
+ .with(authentication(anonymousAuthenticationToken()))).andExpect(status().isUnauthorized());
}
@Test
public void testUsernameAvailable() throws Exception {
String username = "tester";
- // @formatter:off
mvc.perform(get("/registration/username-available/{username}", username))
.andExpect(status().isOk())
.andExpect(content().string("true"));
- // @formatter:on
}
@Test
public void testUsernameAlreadyTaken() throws Exception {
String username = "admin";
- // @formatter:off
mvc.perform(get("/registration/username-available/{username}", username))
.andExpect(status().isOk())
.andExpect(content().string("false"));
- // @formatter:on
}
@Test
@@ -205,12 +205,10 @@ public void testCreateRequestWithoutNotes() throws Exception {
request.setUsername(username);
request.setPassword("password");
- // @formatter:off
- mvc.perform(post("/registration/create")
- .contentType(MediaType.APPLICATION_JSON)
+ mvc
+ .perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
- // @formatter:on
}
@Test
@@ -227,12 +225,10 @@ public void testCreateRequestBlankNotes() throws Exception {
request.setPassword("password");
request.setNotes(" ");
- // @formatter:off
- mvc.perform(post("/registration/create")
- .contentType(MediaType.APPLICATION_JSON)
+ mvc
+ .perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
- // @formatter:on
}
@Test
@@ -263,7 +259,6 @@ private RegistrationRequestDto createRegistrationRequest(String username) throws
request.setNotes("Some short notes...");
request.setPassword("password");
- // @formatter:off
String response = mvc
.perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
@@ -271,18 +266,37 @@ private RegistrationRequestDto createRegistrationRequest(String username) throws
.andReturn()
.getResponse()
.getContentAsString();
- // @formatter:on
return objectMapper.readValue(response, RegistrationRequestDto.class);
}
private void confirmRegistrationRequest(String confirmationKey) throws Exception {
- // @formatter:off
- mvc.perform(get("/registration/confirm/{token}", confirmationKey))
+ mvc
+ .perform(post("/registration/verify").content("token=" + confirmationKey)
+ .contentType(APPLICATION_FORM_URLENCODED))
.andExpect(status().isOk())
- .andExpect(jsonPath("$.status", equalTo(CONFIRMED.name())));
- // @formatter:on
+ .andExpect(model().attributeExists("verificationSuccess"));
+ }
+
+ @Test
+ public void testRegistrationFieldReadOnlyGetterAndSetter() {
+ RegistrationFieldProperties properties = new RegistrationFieldProperties();
+
+ assertFalse(properties.isReadOnly());
+
+ properties.setReadOnly(true);
+ assertTrue(properties.isReadOnly());
}
+ @Test
+ public void testRegistrationFieldExternalAuthAttributeGetterAndSetter() {
+ RegistrationFieldProperties properties = new RegistrationFieldProperties();
+
+ assertNull(properties.getExternalAuthAttribute());
+
+ String testValue = "TestAttribute";
+ properties.setExternalAuthAttribute(testValue);
+ assertEquals(testValue, properties.getExternalAuthAttribute());
+ }
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/SamlExtAuthRegistrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/SamlExtAuthRegistrationTests.java
index b0f4fff86..79ccef863 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/SamlExtAuthRegistrationTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/SamlExtAuthRegistrationTests.java
@@ -22,8 +22,10 @@
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.assertTrue;
+import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -78,7 +80,7 @@ public void externalSamlRegistrationCreatesDisabledAccount() throws Throwable {
request.setUsername(username);
request.setNotes("Some short notes...");
- byte[] requestBytes = mvc
+ mvc
.perform(post("/registration/create").contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(request)))
.andExpect(status().isOk())
@@ -86,7 +88,6 @@ public void externalSamlRegistrationCreatesDisabledAccount() throws Throwable {
.getResponse()
.getContentAsByteArray();
- request = objectMapper.readValue(requestBytes, RegistrationRequestDto.class);
String token = generator.getLastToken();
// If the user tries to authenticate with his external account, he's redirected to the
@@ -119,7 +120,10 @@ public void externalSamlRegistrationCreatesDisabledAccount() throws Throwable {
startsWith("Your registration request to indigo-dc was submitted successfully"));
// the same happens after having confirmed the request
- mvc.perform(get("/registration/confirm/{token}", token)).andExpect(status().isOk());
+ mvc.perform(post("/registration/verify").content("token=" + token)
+ .contentType(APPLICATION_FORM_URLENCODED))
+ .andExpect(status().isOk())
+ .andExpect(model().attributeExists("verificationSuccess"));
session = (MockHttpSession) mvc.perform(get(samlDefaultIdpLoginUrl()))
.andExpect(status().isOk())
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernHrDbApiClientTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernHrDbApiClientTests.java
index c4be6144b..298541059 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernHrDbApiClientTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernHrDbApiClientTests.java
@@ -17,8 +17,8 @@
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.startsWith;
import static org.springframework.http.HttpMethod.GET;
+import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.MediaType.APPLICATION_JSON;
@@ -27,6 +27,8 @@
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import java.util.Optional;
+
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -91,7 +93,7 @@ public void setup() {
}
@Test
- public void checkPersonRecord() throws JsonProcessingException {
+ public void checkPersonRecordReturned() throws JsonProcessingException {
String personId = "12356789";
String voPersonUrl = voPersonUrl(personId);
mockRtf.getMockServer()
@@ -101,13 +103,14 @@ public void checkPersonRecord() throws JsonProcessingException {
.andRespond(withStatus(OK).contentType(APPLICATION_JSON)
.body(mapper.writeValueAsString(mockHrUser(personId))));
- VOPersonDTO user = hrDbService.getHrDbPersonRecord(personId);
- assertThat(user.getFirstName(), is(MOCK_HR_USER_FIRST_NAME));
- assertThat(user.getName(), is(MOCK_HR_USER_FAMILY_NAME));
+ Optional user = hrDbService.getHrDbPersonRecord(personId);
+ assertThat(user.isPresent(), is(true));
+ assertThat(user.get().getFirstName(), is(MOCK_HR_USER_FIRST_NAME));
+ assertThat(user.get().getName(), is(MOCK_HR_USER_FAMILY_NAME));
}
- @Test(expected = CernHrDbApiError.class)
- public void checkErrorPersonRecord() throws JsonProcessingException {
+ @Test
+ public void checkPersonNotFoundMeansEmptyOptional() throws JsonProcessingException {
String personId = "12356789";
String voPersonUrl = voPersonUrl(personId);
mockRtf.getMockServer()
@@ -117,11 +120,19 @@ public void checkErrorPersonRecord() throws JsonProcessingException {
.andRespond(withStatus(NOT_FOUND).contentType(APPLICATION_JSON)
.body(mapper.writeValueAsString(ErrorDTO.newError("NOT_FOUND", "User not found"))));
- try {
- hrDbService.getHrDbPersonRecord(personId);
- } catch (CernHrDbApiError e) {
- assertThat(e.getMessage(), startsWith("HR db api error: 404 Not Found"));
- throw e;
- }
+ assertThat(hrDbService.getHrDbPersonRecord(personId).isEmpty(), is(true));
+ }
+
+ @Test(expected = CernHrDbApiError.class)
+ public void checkApiErrorThrowsException() {
+ String personId = "12356789";
+ String voPersonUrl = voPersonUrl(personId);
+ mockRtf.getMockServer()
+ .expect(requestTo(voPersonUrl))
+ .andExpect(method(GET))
+ .andExpect(header("Authorization", BASIC_AUTH_HEADER_VALUE))
+ .andRespond(withStatus(INTERNAL_SERVER_ERROR));
+
+ hrDbService.getHrDbPersonRecord(personId);
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernRegistrationValidationServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernRegistrationValidationServiceTests.java
index 9e4efcb63..47095e29b 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernRegistrationValidationServiceTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/registration/cern/CernRegistrationValidationServiceTests.java
@@ -122,7 +122,7 @@ private RegistrationRequestDto createDto(String username) {
return request;
}
- private VOPersonDTO mockVoPerson() {
+ private Optional mockVoPerson() {
VOPersonDTO dto = new VOPersonDTO();
dto.setFirstName("TEST");
dto.setName("USER");
@@ -144,10 +144,10 @@ private VOPersonDTO mockVoPerson() {
dto.getParticipations().add(p);
- return dto;
+ return Optional.of(dto);
}
- private VOPersonDTO mockInvalidVoPerson() {
+ private Optional mockInvalidVoPerson() {
VOPersonDTO dto = new VOPersonDTO();
dto.setFirstName("TEST");
dto.setName("USER");
@@ -169,7 +169,7 @@ private VOPersonDTO mockInvalidVoPerson() {
dto.getParticipations().add(p);
- return dto;
+ return Optional.of(dto);
}
@Test
@@ -257,9 +257,9 @@ public void testLabelIsAddedToRegistrationRequest() throws Exception {
assertThat(request.getLabels().get(0).getPrefix(), is("hr.cern"));
assertThat(request.getLabels().get(0).getName(), is("cern_person_id"));
assertThat(request.getLabels().get(0).getValue(), is("988211"));
- assertThat(request.getGivenname(), is(mockVoPerson().getFirstName()));
- assertThat(request.getFamilyname(), is(mockVoPerson().getName()));
- assertThat(request.getEmail(), is(mockVoPerson().getEmail()));
+ assertThat(request.getGivenname(), is(mockVoPerson().get().getFirstName()));
+ assertThat(request.getFamilyname(), is(mockVoPerson().get().getName()));
+ assertThat(request.getEmail(), is(mockVoPerson().get().getEmail()));
mvc.perform(post("/registration/approve/{uuid}", request.getUuid())
.with(user("admin").roles("ADMIN", "USER"))).andExpect(status().isOk());
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/repository/IamTokenRepositoryTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/repository/IamTokenRepositoryTests.java
index fd5248eda..1f313e1b3 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/repository/IamTokenRepositoryTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/repository/IamTokenRepositoryTests.java
@@ -22,6 +22,8 @@
import java.util.Calendar;
import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
import org.apache.commons.lang.time.DateUtils;
import org.junit.Before;
@@ -84,6 +86,8 @@ private OAuth2Authentication oauth2Authentication(ClientDetailsEntity client, St
String[] scopes = {};
Authentication userAuth = null;
+ Map requestParameters = new HashMap();
+ requestParameters.put("grant_type", "authorization_code");
if (username != null) {
scopes = SCOPES;
@@ -91,9 +95,9 @@ private OAuth2Authentication oauth2Authentication(ClientDetailsEntity client, St
}
MockOAuth2Request req = new MockOAuth2Request(client.getClientId(), scopes);
- OAuth2Authentication auth = new OAuth2Authentication(req, userAuth);
+ req.setRequestParameters(requestParameters);
+ return new OAuth2Authentication(req, userAuth);
- return auth;
}
private ClientDetailsEntity loadTestClient() {
@@ -101,9 +105,7 @@ private ClientDetailsEntity loadTestClient() {
}
private OAuth2AccessTokenEntity buildAccessToken(ClientDetailsEntity client, String username) {
- OAuth2AccessTokenEntity token =
- tokenService.createAccessToken(oauth2Authentication(client, username));
- return token;
+ return tokenService.createAccessToken(oauth2Authentication(client, username));
}
private OAuth2AccessTokenEntity buildAccessToken(ClientDetailsEntity client) {
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java
index ef2a24dfd..c3df85dbd 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/ScimRestUtilsMvc.java
@@ -56,6 +56,8 @@ public ResultActions postUser(ScimUser user, HttpStatus expectedStatus) throws E
return doPost(getUsersLocation(), user, SCIM_CONTENT_TYPE, expectedStatus);
}
+
+
public ScimUser getUser(String uuid) throws Exception {
return mapper.readValue(getUser(uuid, OK).andReturn().getResponse().getContentAsString(),
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java
index 7255dcb89..f414813c6 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/core/provisioning/user/ScimUserServiceTests.java
@@ -17,8 +17,8 @@
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
-import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import org.junit.Before;
@@ -73,7 +73,6 @@ public class ScimUserServiceTests {
final String TESTUSER_LABEL_NAME = "label-name";
final String TESTUSER_LABEL_VALUE = "label-value";
final String PRODUCTION_GROUP_UUID = "c617d586-54e6-411d-8e38-64967798fa8a";
-
final String TESTUSER_USERNAME = "testProvisioningUser";
final String TESTUSER_PASSWORD = "password";
final ScimName TESTUSER_NAME = ScimName.builder().givenName("John").familyName("Lennon").build();
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java
index 9ff9b6115..dbc08a072 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/me/patch/ScimMeEndpointPatchAddTests.java
@@ -45,6 +45,7 @@
import it.infn.mw.iam.test.util.WithMockOAuthUser;
import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest;
+
@RunWith(SpringRunner.class)
@IamMockMvcIntegrationTest
@SpringBootTest(
@@ -105,7 +106,7 @@ private void patchMultipleWorks() throws Exception {
private void patchPasswordNotSupported() throws Exception {
String oldPassword = accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getPassword();
ScimUser updates = ScimUser.builder().password("newpassword").build();
@@ -113,7 +114,7 @@ private void patchPasswordNotSupported() throws Exception {
scimUtils.patchMe(add, updates, BAD_REQUEST);
String newPassword = accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getPassword();
assertThat(oldPassword, equalTo(newPassword));
@@ -122,7 +123,7 @@ private void patchPasswordNotSupported() throws Exception {
private void patchAddOidcIdNotSupported() throws Exception {
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getOidcIds()
.isEmpty(), equalTo(true));
@@ -132,7 +133,7 @@ private void patchAddOidcIdNotSupported() throws Exception {
scimUtils.patchMe(add, updates, BAD_REQUEST);
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getOidcIds()
.isEmpty(), equalTo(true));
}
@@ -140,7 +141,7 @@ private void patchAddOidcIdNotSupported() throws Exception {
private void patchAddSamlIdNotSupported() throws Exception {
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getSamlIds()
.isEmpty(), equalTo(true));
@@ -151,7 +152,7 @@ private void patchAddSamlIdNotSupported() throws Exception {
scimUtils.patchMe(add, updates, BAD_REQUEST);
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getSamlIds()
.isEmpty(), equalTo(true));
}
@@ -159,7 +160,7 @@ private void patchAddSamlIdNotSupported() throws Exception {
private void patchAddX509CertificateNotSupported() throws Exception {
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getX509Certificates()
.isEmpty(), equalTo(true));
@@ -173,7 +174,7 @@ private void patchAddX509CertificateNotSupported() throws Exception {
scimUtils.patchMe(add, updates, BAD_REQUEST);
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getX509Certificates()
.isEmpty(), equalTo(true));
}
@@ -181,7 +182,7 @@ private void patchAddX509CertificateNotSupported() throws Exception {
private void patchAddSshKeyIsSupported() throws Exception {
assertThat(accountRepository.findByUsername(TEST_USERNAME)
- .orElseThrow(() -> new IllegalStateException())
+ .orElseThrow(IllegalStateException::new)
.getSshKeys()
.isEmpty(), equalTo(true));
@@ -327,5 +328,6 @@ public void testPatchAddX509CertificateNotSupported() throws Exception {
public void testPatchAddX509CertificateNotSupportedNoToken() throws Exception {
patchAddX509CertificateNotSupported();
+
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java
index 2768c0ae6..67354e44d 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/ScimUserProvisioningTests.java
@@ -339,6 +339,5 @@ public void testEmailIsNotAlreadyLinkedOnUpdate() throws Exception {
.andExpect(jsonPath("$.detail",
containsString("email user1@test.org already assigned to another user")));
-
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java
index d300fc399..15977d202 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/scim/user/x509/ScimX509Tests.java
@@ -15,10 +15,10 @@
*/
package it.infn.mw.iam.test.scim.user.x509;
+import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.MatcherAssert.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java
index fb30a6a07..1c50ce8b8 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/AccountUtilsTests.java
@@ -20,6 +20,7 @@
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.when;
+import java.util.Collections;
import java.util.Optional;
import org.junit.Before;
@@ -36,6 +37,8 @@
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import it.infn.mw.iam.api.account.AccountUtils;
+import it.infn.mw.iam.authn.util.Authorities;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
import it.infn.mw.iam.persistence.model.IamAccount;
import it.infn.mw.iam.persistence.repository.IamAccountRepository;
@@ -51,7 +54,7 @@ public class AccountUtilsTests {
@Mock
IamAccount account;
-
+
@InjectMocks
AccountUtils utils;
@@ -59,7 +62,7 @@ public class AccountUtilsTests {
public void setup() {
SecurityContextHolder.clearContext();
}
-
+
@Test
public void isAuthenticatedReturnsFalseForAnonymousAuthenticationToken() {
@@ -69,72 +72,108 @@ public void isAuthenticatedReturnsFalseForAnonymousAuthenticationToken() {
assertThat(utils.isAuthenticated(), is(false));
}
-
+
@Test
public void isAuthenticatedReturnsFalseForNullAuthentication() {
SecurityContextHolder.createEmptyContext();
assertThat(utils.isAuthenticated(), is(false));
}
-
+
@Test
public void isAuthenticatedReturnsTrueForUsernamePasswordAuthenticationToken() {
- UsernamePasswordAuthenticationToken token = Mockito.mock(UsernamePasswordAuthenticationToken.class);
+ UsernamePasswordAuthenticationToken token =
+ Mockito.mock(UsernamePasswordAuthenticationToken.class);
when(securityContext.getAuthentication()).thenReturn(token);
SecurityContextHolder.setContext(securityContext);
assertThat(utils.isAuthenticated(), is(true));
}
-
+
+ @Test
+ public void isAuthenticatedReturnsFalseForExtendedAuthenticationToken() {
+ ExtendedAuthenticationToken token = Mockito.mock(ExtendedAuthenticationToken.class);
+
+ when(securityContext.getAuthentication()).thenReturn(token);
+ SecurityContextHolder.setContext(securityContext);
+ assertThat(utils.isAuthenticated(), is(false));
+ }
+
+ @Test
+ public void isPreAuthenticatedReturnsFalseForNullAuthentication() {
+ SecurityContextHolder.createEmptyContext();
+ assertThat(utils.isPreAuthenticated(null), is(false));
+ }
+
+ @Test
+ public void isPreAuthenticatedReturnsFalseForEmptyAuthorities() {
+ UsernamePasswordAuthenticationToken token =
+ Mockito.mock(UsernamePasswordAuthenticationToken.class);
+
+ assertThat(utils.isPreAuthenticated(token), is(false));
+ }
+
+ @Test
+ public void isPreAuthenticatedReturnsTrueForProperAuthority() {
+ UsernamePasswordAuthenticationToken token =
+ Mockito.mock(UsernamePasswordAuthenticationToken.class);
+
+ when(token.getAuthorities())
+ .thenReturn(Collections.singleton(Authorities.ROLE_PRE_AUTHENTICATED));
+ assertThat(utils.isPreAuthenticated(token), is(true));
+ }
+
@Test
public void getAuthenticatedUserAccountReturnsEmptyOptionalForNullSecurityContext() {
- assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false));
+ assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false));
}
-
+
@Test
public void getAuthenticatedUserAccountReturnsEmptyOptionalForAnonymousSecurityContext() {
AnonymousAuthenticationToken anonymousToken = Mockito.mock(AnonymousAuthenticationToken.class);
when(securityContext.getAuthentication()).thenReturn(anonymousToken);
SecurityContextHolder.setContext(securityContext);
- assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false));
+ assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false));
}
-
+
@Test
public void getAuthenticatedUserAccountWorksForUsernamePasswordAuthenticationToken() {
when(account.getUsername()).thenReturn("test");
when(repo.findByUsername("test")).thenReturn(Optional.of(account));
-
- UsernamePasswordAuthenticationToken token = Mockito.mock(UsernamePasswordAuthenticationToken.class);
+
+ UsernamePasswordAuthenticationToken token =
+ Mockito.mock(UsernamePasswordAuthenticationToken.class);
when(token.getName()).thenReturn("test");
when(securityContext.getAuthentication()).thenReturn(token);
SecurityContextHolder.setContext(securityContext);
-
+
Optional authUserAccount = utils.getAuthenticatedUserAccount();
assertThat(authUserAccount.isPresent(), is(true));
assertThat(authUserAccount.get().getUsername(), equalTo("test"));
-
+
}
-
+
@Test
public void getAuthenticatedUserAccountWorksForOauthToken() {
when(account.getUsername()).thenReturn("test");
when(repo.findByUsername("test")).thenReturn(Optional.of(account));
-
- UsernamePasswordAuthenticationToken token = Mockito.mock(UsernamePasswordAuthenticationToken.class);
+
+ UsernamePasswordAuthenticationToken token =
+ Mockito.mock(UsernamePasswordAuthenticationToken.class);
when(token.getName()).thenReturn("test");
-
+
OAuth2Authentication oauth = Mockito.mock(OAuth2Authentication.class);
when(oauth.getUserAuthentication()).thenReturn(token);
-
+
when(securityContext.getAuthentication()).thenReturn(oauth);
SecurityContextHolder.setContext(securityContext);
-
+
Optional authUserAccount = utils.getAuthenticatedUserAccount();
assertThat(authUserAccount.isPresent(), is(true));
assertThat(authUserAccount.get().getUsername(), equalTo("test"));
-
+
}
-
+
@Test
public void getAuthenticatedUserAccountReturnsEmptyOptionalForClientOAuthToken() {
OAuth2Authentication oauth = Mockito.mock(OAuth2Authentication.class);
@@ -142,7 +181,7 @@ public void getAuthenticatedUserAccountReturnsEmptyOptionalForClientOAuthToken()
when(oauth.getUserAuthentication()).thenReturn(null);
when(securityContext.getAuthentication()).thenReturn(oauth);
SecurityContextHolder.setContext(securityContext);
-
- assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false));
+
+ assertThat(utils.getAuthenticatedUserAccount().isPresent(), is(false));
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java
index b8221a4c4..585100abc 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTestSupport.java
@@ -44,27 +44,27 @@ public class IamAccountServiceTestSupport {
public static final String TEST_OIDC_ID_ISSUER = "oidcIssuer";
public static final String TEST_OIDC_ID_SUBJECT = "oidcSubject";
-
+
public static final String TEST_SSH_KEY_VALUE_1 = "ssh-key-value-1";
public static final String TEST_SSH_KEY_VALUE_2 = "ssh-key-value-2";
-
+
public static final String TEST_X509_CERTIFICATE_VALUE_1 = "x509-cert-value-1";
public static final String TEST_X509_CERTIFICATE_SUBJECT_1 = "x509-cert-subject-1";
public static final String TEST_X509_CERTIFICATE_ISSUER_1 = "x509-cert-issuer-1";
public static final String TEST_X509_CERTIFICATE_LABEL_1 = "x509-cert-label-1";
-
+
public static final String TEST_X509_CERTIFICATE_VALUE_2 = "x509-cert-value-2";
public static final String TEST_X509_CERTIFICATE_SUBJECT_2 = "x509-cert-subject-2";
public static final String TEST_X509_CERTIFICATE_ISSUER_2 = "x509-cert-issuer-2";
public static final String TEST_X509_CERTIFICATE_LABEL_2 = "x509-cert-label-2";
-
-
+
+
protected final IamAccount TEST_ACCOUNT;
protected final IamAccount CICCIO_ACCOUNT;
protected final IamAuthority ROLE_USER_AUTHORITY;
protected final IamSamlId TEST_SAML_ID;
protected final IamOidcId TEST_OIDC_ID;
-
+
protected final IamSshKey TEST_SSH_KEY_1;
protected final IamSshKey TEST_SSH_KEY_2;
protected final IamX509Certificate TEST_X509_CERTIFICATE_1;
@@ -77,7 +77,7 @@ public IamAccountServiceTestSupport() {
TEST_ACCOUNT.getUserInfo().setEmail(TEST_EMAIL);
TEST_ACCOUNT.getUserInfo().setGivenName(TEST_GIVEN_NAME);
TEST_ACCOUNT.getUserInfo().setFamilyName(TEST_FAMILY_NAME);
-
+
ROLE_USER_AUTHORITY = new IamAuthority("ROLE_USER");
CICCIO_ACCOUNT = IamAccount.newAccount();
@@ -89,27 +89,22 @@ public IamAccountServiceTestSupport() {
TEST_SAML_ID =
new IamSamlId(TEST_SAML_ID_IDP_ID, TEST_SAML_ID_ATTRIBUTE_ID, TEST_SAML_ID_USER_ID);
-
- TEST_OIDC_ID =
- new IamOidcId(TEST_OIDC_ID_ISSUER, TEST_OIDC_ID_SUBJECT);
-
- TEST_SSH_KEY_1 =
- new IamSshKey(TEST_SSH_KEY_VALUE_1);
-
- TEST_SSH_KEY_2 =
- new IamSshKey(TEST_SSH_KEY_VALUE_2);
-
- TEST_X509_CERTIFICATE_1 =
- new IamX509Certificate();
-
+
+ TEST_OIDC_ID = new IamOidcId(TEST_OIDC_ID_ISSUER, TEST_OIDC_ID_SUBJECT);
+
+ TEST_SSH_KEY_1 = new IamSshKey(TEST_SSH_KEY_VALUE_1);
+
+ TEST_SSH_KEY_2 = new IamSshKey(TEST_SSH_KEY_VALUE_2);
+
+ TEST_X509_CERTIFICATE_1 = new IamX509Certificate();
+
TEST_X509_CERTIFICATE_1.setLabel(TEST_X509_CERTIFICATE_LABEL_1);
TEST_X509_CERTIFICATE_1.setSubjectDn(TEST_X509_CERTIFICATE_SUBJECT_1);
TEST_X509_CERTIFICATE_1.setIssuerDn(TEST_X509_CERTIFICATE_ISSUER_1);
TEST_X509_CERTIFICATE_1.setCertificate(TEST_X509_CERTIFICATE_VALUE_1);
-
- TEST_X509_CERTIFICATE_2 =
- new IamX509Certificate();
-
+
+ TEST_X509_CERTIFICATE_2 = new IamX509Certificate();
+
TEST_X509_CERTIFICATE_2.setLabel(TEST_X509_CERTIFICATE_LABEL_2);
TEST_X509_CERTIFICATE_2.setSubjectDn(TEST_X509_CERTIFICATE_SUBJECT_2);
TEST_X509_CERTIFICATE_2.setIssuerDn(TEST_X509_CERTIFICATE_ISSUER_2);
@@ -124,8 +119,8 @@ public IamAccount cloneAccount(IamAccount account) {
newAccount.getUserInfo().setGivenName(account.getUserInfo().getGivenName());
newAccount.getUserInfo().setFamilyName(account.getUserInfo().getFamilyName());
+ newAccount.touch();
+
return newAccount;
}
-
-
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java
index f044c1fe6..c882279d4 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/service/IamAccountServiceTests.java
@@ -42,6 +42,8 @@
import java.util.List;
import java.util.Optional;
+import com.google.common.collect.Sets;
+
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -57,8 +59,6 @@
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
-import com.google.common.collect.Sets;
-
import it.infn.mw.iam.audit.events.account.AccountEndTimeUpdatedEvent;
import it.infn.mw.iam.audit.events.account.EmailReplacedEvent;
import it.infn.mw.iam.audit.events.account.FamilyNameReplacedEvent;
@@ -148,7 +148,6 @@ public void setup() {
when(accountRepo.findByUsername(TEST_USERNAME)).thenReturn(Optional.of(TEST_ACCOUNT));
when(accountRepo.findByEmail(TEST_EMAIL)).thenReturn(Optional.of(TEST_ACCOUNT));
when(accountRepo.findByEmailWithDifferentUUID(TEST_EMAIL, CICCIO_UUID)).thenThrow(EmailAlreadyBoundException.class);
-
when(authoritiesRepo.findByAuthority(anyString())).thenReturn(Optional.empty());
when(authoritiesRepo.findByAuthority("ROLE_USER")).thenReturn(Optional.of(ROLE_USER_AUTHORITY));
when(passwordEncoder.encode(any())).thenReturn(PASSWORD);
@@ -1056,5 +1055,4 @@ public void testNoDefaultGroupsAddedWhenDefaultGroupsNotGiven() {
Optional groupMembershipOptional = account.getGroups().stream().findFirst();
assertFalse(groupMembershipOptional.isPresent());
}
-
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java
index 0a09ab384..dbeae03e9 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/AuthenticationUtils.java
@@ -27,6 +27,12 @@ public static Authentication adminAuthentication() {
}
public static Authentication userAuthentication() {
- return new UsernamePasswordAuthenticationToken("test", "", AuthorityUtils.createAuthorityList("ROLE_USER"));
+ return new UsernamePasswordAuthenticationToken("test", "",
+ AuthorityUtils.createAuthorityList("ROLE_USER"));
+ }
+
+ public static Authentication preAuthenticatedAuthentication() {
+ return new UsernamePasswordAuthenticationToken("test_pre_authenticated", "",
+ AuthorityUtils.createAuthorityList("ROLE_PRE_AUTHENTICATED"));
}
}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockMfaUser.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockMfaUser.java
new file mode 100644
index 000000000..556544041
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockMfaUser.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.util;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import org.springframework.security.test.context.support.WithSecurityContext;
+
+import it.infn.mw.iam.test.util.multi_factor_authentication.WithMockMfaUserSecurityContextFactory;
+
+@Retention(RetentionPolicy.RUNTIME)
+@WithSecurityContext(factory = WithMockMfaUserSecurityContextFactory.class)
+public @interface WithMockMfaUser {
+
+ String username() default "test-mfa-user";
+
+ String[] authorities() default {"ROLE_USER"};
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockPreAuthenticatedUser.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockPreAuthenticatedUser.java
new file mode 100644
index 000000000..910a5baff
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/WithMockPreAuthenticatedUser.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.util;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import org.springframework.security.test.context.support.WithSecurityContext;
+
+import it.infn.mw.iam.test.util.multi_factor_authentication.WithMockPreAuthenticatedUserSecurityContextFactory;
+
+@Retention(RetentionPolicy.RUNTIME)
+@WithSecurityContext(factory = WithMockPreAuthenticatedUserSecurityContextFactory.class)
+public @interface WithMockPreAuthenticatedUser {
+
+ String username() default "test-mfa-user";
+
+ String[] authorities() default {"ROLE_PRE_AUTHENTICATED"};
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java
index 989082579..e8e00c074 100644
--- a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/annotation/IamMockMvcIntegrationTest.java
@@ -37,6 +37,7 @@
classes = {IamLoginService.class, CoreControllerTestSupport.class, ScimRestUtilsMvc.class},
webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc(printOnlyOnFailure = true, print = MockMvcPrint.LOG_DEBUG)
+
@Transactional
public @interface IamMockMvcIntegrationTest {
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockMfaUserSecurityContextFactory.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockMfaUserSecurityContextFactory.java
new file mode 100644
index 000000000..36f1a6c4c
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockMfaUserSecurityContextFactory.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.util.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.PASSWORD;
+import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.ONE_TIME_PASSWORD;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.test.context.support.WithSecurityContextFactory;
+
+import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+import it.infn.mw.iam.test.util.WithMockMfaUser;
+
+public class WithMockMfaUserSecurityContextFactory
+ implements WithSecurityContextFactory {
+
+ @Override
+ public SecurityContext createSecurityContext(WithMockMfaUser annotation) {
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+
+ IamAuthenticationMethodReference pwd =
+ new IamAuthenticationMethodReference(PASSWORD.getValue());
+ IamAuthenticationMethodReference otp =
+ new IamAuthenticationMethodReference(ONE_TIME_PASSWORD.getValue());
+ Set refs =
+ new HashSet(Arrays.asList(pwd, otp));
+
+ ExtendedAuthenticationToken token = new ExtendedAuthenticationToken(annotation.username(), "",
+ AuthorityUtils.createAuthorityList(annotation.authorities()));
+ token.setAuthenticated(true);
+ token.setAuthenticationMethodReferences(refs);
+ context.setAuthentication(token);
+ return context;
+ }
+}
diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockPreAuthenticatedUserSecurityContextFactory.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockPreAuthenticatedUserSecurityContextFactory.java
new file mode 100644
index 000000000..5a11f8f27
--- /dev/null
+++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/util/multi_factor_authentication/WithMockPreAuthenticatedUserSecurityContextFactory.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package it.infn.mw.iam.test.util.multi_factor_authentication;
+
+import static it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference.AuthenticationMethodReferenceValues.PASSWORD;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.test.context.support.WithSecurityContextFactory;
+
+import it.infn.mw.iam.authn.multi_factor_authentication.IamAuthenticationMethodReference;
+import it.infn.mw.iam.core.ExtendedAuthenticationToken;
+import it.infn.mw.iam.test.util.WithMockPreAuthenticatedUser;
+
+public class WithMockPreAuthenticatedUserSecurityContextFactory
+ implements WithSecurityContextFactory {
+
+ @Override
+ public SecurityContext createSecurityContext(WithMockPreAuthenticatedUser annotation) {
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+
+ IamAuthenticationMethodReference pwd =
+ new IamAuthenticationMethodReference(PASSWORD.getValue());
+ Set refs =
+ new HashSet(Arrays.asList(pwd));
+
+ ExtendedAuthenticationToken token = new ExtendedAuthenticationToken(annotation.username(), "",
+ AuthorityUtils.createAuthorityList(annotation.authorities()));
+ token.setAuthenticated(false);
+ token.setAuthenticationMethodReferences(refs);
+ context.setAuthentication(token);
+ return context;
+ }
+}
diff --git a/iam-login-service/src/test/resources/x509/oldtest0.cert.pem b/iam-login-service/src/test/resources/x509/oldtest0.cert.pem
new file mode 100644
index 000000000..39e8033e6
--- /dev/null
+++ b/iam-login-service/src/test/resources/x509/oldtest0.cert.pem
@@ -0,0 +1,85 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number: 1 (0x1)
+ Signature Algorithm: sha512WithRSAEncryption
+ Issuer: C=IT, O=IGI, CN=Test CA 2
+ Validity
+ Not Before: Jan 27 13:42:04 2015 GMT
+ Not After : Jan 24 13:42:04 2025 GMT
+ Subject: C=IT, O=IGI, CN=test0
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ RSA Public Key: (2048 bit)
+ Modulus (2048 bit):
+ 00:c1:9c:9a:0b:eb:02:f1:ee:fe:58:ef:94:9a:02:
+ a9:52:f2:bf:18:3a:fd:77:5b:36:9a:79:b5:45:1b:
+ cd:31:3d:b5:82:51:5c:88:54:0e:58:a5:2b:11:96:
+ 4a:6e:63:a3:c4:2d:10:09:68:bd:22:24:2b:eb:56:
+ 25:11:cc:12:73:93:17:28:0a:91:aa:29:b1:7b:dd:
+ 2e:33:ed:49:22:34:27:9a:25:a8:1a:8e:c5:95:69:
+ 2f:dd:86:3f:77:42:1a:ac:c8:b4:81:4f:01:da:94:
+ 1e:54:57:a4:ad:fd:ab:34:11:d9:04:6b:4b:a7:57:
+ 77:e1:dd:d9:4c:07:8c:90:a1:57:07:89:01:b1:22:
+ bb:11:8d:ed:c2:30:a6:3c:b0:49:06:6a:30:b3:5d:
+ 47:18:2f:32:5c:91:aa:b5:5c:e7:46:0f:88:da:05:
+ ac:13:c2:2a:81:9a:64:04:be:44:bc:72:04:80:9d:
+ 02:3e:92:1a:83:35:1a:ec:35:35:31:0a:1c:6f:ab:
+ c1:64:ce:1f:c1:ea:aa:14:67:a4:d7:94:e3:1f:b5:
+ 72:d6:de:71:55:83:97:51:2f:2a:9e:c5:31:72:c0:
+ 6b:d2:d9:8b:f5:1c:3a:33:47:49:19:d9:30:0d:5e:
+ 4a:1c:b0:63:6b:be:49:b3:67:1b:ac:e6:b5:3c:d5:
+ a1:e7
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Basic Constraints: critical
+ CA:FALSE
+ X509v3 Subject Key Identifier:
+ 37:E1:3B:FB:BC:51:2A:BF:FF:47:4F:5C:54:70:D1:5B:E7:DF:38:B0
+ X509v3 Key Usage: critical
+ Digital Signature, Non Repudiation, Key Encipherment
+ X509v3 Extended Key Usage:
+ TLS Web Server Authentication, TLS Web Client Authentication, Microsoft Server Gated Crypto, Netscape Server Gated Crypto, E-mail Protection
+ X509v3 Authority Key Identifier:
+ keyid:37:03:CB:96:42:3A:14:8F:BD:5B:72:2A:EE:F0:6B:A8:6D:4F:8C:44
+
+ X509v3 Subject Alternative Name:
+ email:andrea.ceccanti@cnaf.infn.it
+ Signature Algorithm: sha512WithRSAEncryption
+ bb:f5:cc:38:62:0e:c6:86:66:bf:88:6e:91:8b:74:27:02:2e:
+ 60:83:6d:68:34:74:7f:c0:04:60:30:87:1d:21:a6:2e:ee:8d:
+ 32:65:b7:1d:f8:42:96:9e:9c:0b:41:fc:57:b9:d0:73:57:13:
+ 04:1d:d0:8d:e9:93:4a:63:e3:dc:8c:a3:75:66:a0:73:fe:e6:
+ 06:0c:fb:7a:73:7f:e0:d1:05:8c:c9:7b:ad:7d:54:4b:e3:23:
+ 63:0b:0f:ff:f3:36:a3:7e:b2:7e:e9:e3:dd:f7:2d:b4:c8:95:
+ 4d:55:91:05:9f:f8:56:2e:a0:c7:c3:1f:7e:07:6d:6a:0c:94:
+ 44:e1:6f:dd:d5:b4:76:8a:6b:29:57:50:09:02:1f:fa:9a:e6:
+ ce:ad:c9:1e:28:b9:db:51:a0:94:dc:41:f1:a0:2f:b9:78:af:
+ 70:93:df:c0:d4:c1:87:ba:e4:93:0f:37:2a:f6:16:14:6b:df:
+ 1e:d9:02:3c:6c:79:5d:6e:37:1a:7f:77:e5:b6:9d:89:63:45:
+ b0:28:73:c4:32:6d:0e:11:86:60:87:47:01:5d:38:5d:c0:01:
+ 36:30:3b:07:35:e6:2d:02:1c:f1:ee:9c:d6:5d:e9:4d:8c:25:
+ b0:e2:d6:0b:b8:e0:a7:b8:fd:94:3a:bd:44:50:5c:58:33:1b:
+ fc:82:48:2a
+-----BEGIN CERTIFICATE-----
+MIIDoDCCAoigAwIBAgIBATANBgkqhkiG9w0BAQ0FADAvMQswCQYDVQQGEwJJVDEM
+MAoGA1UECgwDSUdJMRIwEAYDVQQDDAlUZXN0IENBIDIwHhcNMTUwMTI3MTM0MjA0
+WhcNMjUwMTI0MTM0MjA0WjArMQswCQYDVQQGEwJJVDEMMAoGA1UECgwDSUdJMQ4w
+DAYDVQQDDAV0ZXN0MDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMGc
+mgvrAvHu/ljvlJoCqVLyvxg6/XdbNpp5tUUbzTE9tYJRXIhUDlilKxGWSm5jo8Qt
+EAlovSIkK+tWJRHMEnOTFygKkaopsXvdLjPtSSI0J5olqBqOxZVpL92GP3dCGqzI
+tIFPAdqUHlRXpK39qzQR2QRrS6dXd+Hd2UwHjJChVweJAbEiuxGN7cIwpjywSQZq
+MLNdRxgvMlyRqrVc50YPiNoFrBPCKoGaZAS+RLxyBICdAj6SGoM1Guw1NTEKHG+r
+wWTOH8HqqhRnpNeU4x+1ctbecVWDl1EvKp7FMXLAa9LZi/UcOjNHSRnZMA1eShyw
+Y2u+SbNnG6zmtTzVoecCAwEAAaOByjCBxzAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW
+BBQ34Tv7vFEqv/9HT1xUcNFb5984sDAOBgNVHQ8BAf8EBAMCBeAwPgYDVR0lBDcw
+NQYIKwYBBQUHAwEGCCsGAQUFBwMCBgorBgEEAYI3CgMDBglghkgBhvhCBAEGCCsG
+AQUFBwMEMB8GA1UdIwQYMBaAFDcDy5ZCOhSPvVtyKu7wa6htT4xEMCcGA1UdEQQg
+MB6BHGFuZHJlYS5jZWNjYW50aUBjbmFmLmluZm4uaXQwDQYJKoZIhvcNAQENBQAD
+ggEBALv1zDhiDsaGZr+IbpGLdCcCLmCDbWg0dH/ABGAwhx0hpi7ujTJltx34Qpae
+nAtB/Fe50HNXEwQd0I3pk0pj49yMo3VmoHP+5gYM+3pzf+DRBYzJe619VEvjI2ML
+D//zNqN+sn7p4933LbTIlU1VkQWf+FYuoMfDH34HbWoMlEThb93VtHaKaylXUAkC
+H/qa5s6tyR4oudtRoJTcQfGgL7l4r3CT38DUwYe65JMPNyr2FhRr3x7ZAjxseV1u
+Nxp/d+W2nYljRbAoc8QybQ4RhmCHRwFdOF3AATYwOwc15i0CHPHunNZd6U2MJbDi
+1gu44Ke4/ZQ6vURQXFgzG/yCSCo=
+-----END CERTIFICATE-----
diff --git a/iam-login-service/src/test/resources/x509/oldtest0.key.pem b/iam-login-service/src/test/resources/x509/oldtest0.key.pem
new file mode 100644
index 000000000..1ab70f4dd
--- /dev/null
+++ b/iam-login-service/src/test/resources/x509/oldtest0.key.pem
@@ -0,0 +1,30 @@
+-----BEGIN RSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: DES-EDE3-CBC,F1D6F60CA80A056E
+
+41p5GGqyc/JPja0I6M+gC/s0UW3adx1Tk6ql8VbwVknm8WCs9vSjjeLQ4bEFUBlZ
+rdN2jYjHoA6t735h5+PEckS6FxqSUF0VciEOSJCn7ZqOYWBoGXDUuss+u6iZJqWb
+NjKsX+KRw/myPjB1x7opY/0Qu62wlBlLAt4l4rl1ovvErgTmoXc9Zt9JTte1VrmD
+nj9/FBL0sfUb5cz5ElysqYsUAL32Jsest9XGLQnX0ywCvLNBFh3FsT3ULkGwTdBL
+gqN/RvyZnNF0IX1A21YhitzYHKUimq0R119UD2OfdWvz8WdB0asvG4SxqsAJekyV
+4lrIkXQHXn5VK/tcIkH7AU6QQKd5my4VOukB3IBwXzsd9Xt2eKaFsdVC9UaC5tFq
+6FcIw0LhQZtLK4ZL16cI9LvTirpRMkIhRPOtM9KrNsQ2cDkIdTyMhRVXQ+fFXmLy
+cQ8S9cjB38Hj69p0sGbzg0U+HxmMBIT2gin/Py3GO8hJgOzsW9tWxeeraQA8SYt5
+XMhvh4sLb20OHOsv01qlgO8UjBdJRCEMsmz+ed3wmVzD4zIBF4SxZQ+BTZSlQTif
+xpOw44kVuIyq2ed3ruxaWqbm5g0s+r799p5ts5ObyybVheb+lape7NBYXO5zDK6z
+IYMSjNacB8Bb+ZAG2ZqK78e1q5hvNjckKTvlWU45ivdNGaugX8x58y40svUAmn60
+nQAg1nQJ+8UAOOLW+FbFvbf2sDfvtmTL4uG4TGM3uwrTBv17Pk52YBMAaM6zgKTP
+pr/LP8DFQzibDaAzx+SrtLWQ9G3/+Gk1Us85iez2ZtxYjopmOmb3vQtKEiIEybEc
+8CDI2iAlWKTNtDoZw+xZgMnK9+E2bnzdolV8CTi6jDDyo4UnLNLmskMUiLr+eD9T
+ZvsL4kb/rnBP+Ffuen+iqRK3U2EdLrIiCB4rZO+6tC2ZXQVUStzdQo2jP06DeZ3c
+ncjXYexNqu1sL1oV9QO9HeCq89DhtlZYltoQgIGWD7GebgUILuI/PyGWVtrS2ERQ
+zXm/31KhU3iXktEIP8yV0LEBct9tCV1xQhMqrDzdJe0FeB4hnT5gdKdiJAfnPYd9
+dOH+d7E24CRDunJS43mSx+HRiYij+UnCxNi5hyeDXNIBNiLL/AwF3xGJ7mOMjn4p
+wy1WcQkLnDYsth92jv22j59MiqEbddrCEkkuwEKOJs5AdmkCWNHuLP5fIQ6E+o32
+3G4MOABAWJcRCvbDSnLuT5jWlgmGetrxJSjhi4YUkwfTCh0yN0AfoSBgEEWGvHxY
+HtqbFXInkGQsQQmhEb7bGnwyKVCKZeSJHBqWkrqB2fVByhSk2DoeTWWwksOqznI5
+Pt2UrZty/WqV3vNtm7UNLmrFg71S6Hik9lcSnqCYZOiLah6Yx0AUPEml50Ap2RGU
+QxVquWT8Yz9z7dvVIHvyCwzKTLfMLr2DmdFh2D7IO+7PG/OM4Pfg+qrfVGpr1XlV
+P01jkdUyvzQyxgsnN/70X++7NXoHi5oPVX2P3J6CjmXiPEUKvHgtUuTqwZ1Wm5nm
+q6qE16oWvE6CnAGaYB+51UWKOmRW5zxX09smDSZuYNSeTYdTX025D70lvqPfSxWS
+-----END RSA PRIVATE KEY-----
diff --git a/iam-persistence/pom.xml b/iam-persistence/pom.xml
index bc56bfe9e..cfac508e3 100644
--- a/iam-persistence/pom.xml
+++ b/iam-persistence/pom.xml
@@ -22,7 +22,7 @@
it.infn.mw.iam-parentiam-parent
- 1.10.2
+ 1.11.0it.infn.mw.iam-persistence
@@ -108,6 +108,11 @@
jaxb-runtime
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-joda
+
+
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java b/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java
index ba3c26c74..774d5c380 100644
--- a/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java
+++ b/iam-persistence/src/main/java/it/infn/mw/iam/core/IamNotificationType.java
@@ -16,5 +16,6 @@
package it.infn.mw.iam.core;
public enum IamNotificationType {
- CONFIRMATION, RESETPASSWD, ACTIVATED, REJECTED, GROUP_MEMBERSHIP, AUP_REMINDER, AUP_EXPIRATION, AUP_SIGNATURE_REQUEST, ACCOUNT_SUSPENDED, ACCOUNT_RESTORED, CLIENT_STATUS
+ CONFIRMATION, RESETPASSWD, ACTIVATED, REJECTED, GROUP_MEMBERSHIP, AUP_REMINDER, AUP_EXPIRATION, AUP_SIGNATURE_REQUEST,
+ ACCOUNT_SUSPENDED, ACCOUNT_RESTORED, CLIENT_STATUS, CERTIFICATE_LINK, MFA_ENABLE, MFA_DISABLE
}
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java
index 6a90392c4..522c0bae8 100644
--- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java
+++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamAccount.java
@@ -16,6 +16,8 @@
package it.infn.mw.iam.persistence.model;
import static com.google.common.base.Preconditions.checkNotNull;
+import static java.lang.Boolean.logicalOr;
+import static java.util.Objects.isNull;
import java.io.Serializable;
import java.time.Clock;
@@ -48,6 +50,8 @@
import javax.persistence.TemporalType;
import javax.validation.constraints.NotNull;
+import org.joda.time.DateTimeComparator;
+
import com.google.common.base.Preconditions;
@Entity
@@ -613,4 +617,8 @@ public Date getEndTime() {
public void setEndTime(Date endTime) {
this.endTime = endTime;
}
+
+ public boolean isValid() {
+ return logicalOr(isNull(endTime), DateTimeComparator.getInstance().compare(endTime, new Date()) > 0);
+ }
}
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamScopePolicy.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamScopePolicy.java
index f2605ae70..f3bdc4a48 100644
--- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamScopePolicy.java
+++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamScopePolicy.java
@@ -218,6 +218,7 @@ public void from(IamScopePolicy other) {
setDescription(other.getDescription());
setRule(other.getRule());
setScopes(other.getScopes());
+ setMatchingPolicy(other.getMatchingPolicy());
linkAccount();
linkGroup();
}
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java
index b92328f89..a15d4829c 100644
--- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java
+++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpMfa.java
@@ -17,17 +17,13 @@
import java.io.Serializable;
import java.util.Date;
-import java.util.HashSet;
-import java.util.Set;
-import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
-import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
-import javax.persistence.OneToMany;
+import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import javax.persistence.Temporal;
@@ -43,7 +39,8 @@ public class IamTotpMfa implements Serializable {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @OneToOne()
+ @OneToOne
+ @JoinColumn(name = "account_id")
private IamAccount account;
@Column(name = "secret", nullable = false)
@@ -60,10 +57,6 @@ public class IamTotpMfa implements Serializable {
@Column(name = "last_update_time", nullable = false)
private Date lastUpdateTime;
- @OneToMany(mappedBy = "totpMfa", cascade = CascadeType.ALL, fetch = FetchType.EAGER,
- orphanRemoval = true)
- private Set recoveryCodes = new HashSet<>();
-
public IamTotpMfa() {
Date now = new Date();
setCreationTime(now);
@@ -136,15 +129,6 @@ public void touch() {
setLastUpdateTime(new Date());
}
- public Set getRecoveryCodes() {
- return recoveryCodes;
- }
-
- public void setRecoveryCodes(final Set recoveryCodes) {
- this.recoveryCodes.clear();
- this.recoveryCodes.addAll(recoveryCodes);
- }
-
@Override
public String toString() {
return "IamTotpMfa [active=" + active + ", id=" + id + ", secret=" + secret + "]";
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpRecoveryCode.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpRecoveryCode.java
deleted file mode 100644
index c76bc0077..000000000
--- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/model/IamTotpRecoveryCode.java
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package it.infn.mw.iam.persistence.model;
-
-import java.io.Serializable;
-
-import javax.persistence.Column;
-import javax.persistence.Entity;
-import javax.persistence.FetchType;
-import javax.persistence.GeneratedValue;
-import javax.persistence.GenerationType;
-import javax.persistence.Id;
-import javax.persistence.JoinColumn;
-import javax.persistence.ManyToOne;
-import javax.persistence.Table;
-
-@Entity
-@Table(name = "iam_totp_recovery_code")
-public class IamTotpRecoveryCode implements Serializable {
-
- private static final long serialVersionUID = 1L;
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- @Column(name = "code")
- private String code;
-
- @ManyToOne(fetch = FetchType.EAGER)
- @JoinColumn(referencedColumnName = "id", nullable = false, name = "totp_mfa_id")
- private IamTotpMfa totpMfa;
-
- public IamTotpRecoveryCode() {}
-
- public IamTotpRecoveryCode(IamTotpMfa totpMfa) {
- this.totpMfa = totpMfa;
- }
-
- public Long getId() {
- return id;
- }
-
- public void setId(Long id) {
- this.id = id;
- }
-
- public IamTotpMfa getTotpMfa() {
- return totpMfa;
- }
-
- public void setTotpMfa(final IamTotpMfa totpMfa) {
- this.totpMfa = totpMfa;
- }
-
- public String getCode() {
- return code;
- }
-
- public void setCode(final String code) {
- this.code = code;
- }
-
- @Override
- public String toString() {
- return "IamTotpRecoveryCode [code=" + code + ", id=" + id + "]";
- }
-
- @Override
- public int hashCode() {
- final int prime = 31;
- int result = 1;
- result = prime * result + ((code == null) ? 0 : code.hashCode());
- result = prime * result + ((id == null) ? 0 : id.hashCode());
- result = prime * result + ((totpMfa == null) ? 0 : totpMfa.hashCode());
- return result;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (this == obj)
- return true;
- if (obj == null)
- return false;
- if (getClass() != obj.getClass())
- return false;
- IamTotpRecoveryCode other = (IamTotpRecoveryCode) obj;
- if (code == null) {
- if (other.code != null)
- return false;
- } else if (!code.equals(other.code))
- return false;
- if (id == null) {
- if (other.id != null)
- return false;
- } else if (!id.equals(other.id))
- return false;
- if (totpMfa == null) {
- if (other.totpMfa != null)
- return false;
- } else if (!totpMfa.equals(other.totpMfa))
- return false;
- return true;
- }
-}
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java
index a8975abb1..f011a206c 100644
--- a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java
+++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamTotpMfaRepositoryImpl.java
@@ -33,5 +33,4 @@ public class IamTotpMfaRepositoryImpl implements IamTotpMfaRepositoryCustom {
public Optional findByAccount(IamAccount account) {
return repo.findByAccountId(account.getId());
}
-
}
diff --git a/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamX509CertificateRepository.java b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamX509CertificateRepository.java
new file mode 100644
index 000000000..7ce6d4ed7
--- /dev/null
+++ b/iam-persistence/src/main/java/it/infn/mw/iam/persistence/repository/IamX509CertificateRepository.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.persistence.repository;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.PagingAndSortingRepository;
+import org.springframework.data.repository.query.Param;
+
+import it.infn.mw.iam.persistence.model.IamAccount;
+import it.infn.mw.iam.persistence.model.IamX509Certificate;
+
+public interface IamX509CertificateRepository
+ extends PagingAndSortingRepository {
+
+ @Query("select c.account from IamX509Certificate c where c.subjectDn = :subject")
+ List findBySubjectDn(@Param("subject") String subject);
+
+ public Optional findBySubjectDnAndIssuerDn(String subjectDn, String issuerDn);
+
+}
\ No newline at end of file
diff --git a/iam-persistence/src/main/resources/db/migration/h2/V97__delete_unique_subject_dn.sql b/iam-persistence/src/main/resources/db/migration/h2/V97__delete_unique_subject_dn.sql
index b63dbad8d..c0fa36b21 100644
--- a/iam-persistence/src/main/resources/db/migration/h2/V97__delete_unique_subject_dn.sql
+++ b/iam-persistence/src/main/resources/db/migration/h2/V97__delete_unique_subject_dn.sql
@@ -1,2 +1,3 @@
-ALTER TABLE iam_x509_cert DROP CONSTRAINT CONSTRAINT_32;
+ALTER TABLE iam_x509_cert DROP CONSTRAINT IF EXISTS CONSTRAINT_32;
+ALTER TABLE iam_x509_cert DROP CONSTRAINT IF EXISTS UNIQ_IAM_X509_CERT_CERIFICATE;
CREATE INDEX idx_subject_dn ON iam_x509_cert(subject_dn);
\ No newline at end of file
diff --git a/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql b/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql
index 0d2b46151..6370b4dd7 100644
--- a/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql
+++ b/iam-persistence/src/main/resources/db/migration/test/V100000___test_data.sql
@@ -21,12 +21,12 @@ INSERT INTO client_details (id, client_id, client_secret, client_name, dynamical
INSERT INTO client_details (id, client_id, client_secret, client_name, dynamically_registered,
refresh_token_validity_seconds, access_token_validity_seconds, id_token_validity_seconds, allow_introspection,
- token_endpoint_auth_method, require_auth_time, token_endpoint_auth_signing_alg, jwks, active) VALUES
+ token_endpoint_auth_method, require_auth_time, token_endpoint_auth_signing_alg, jwks) VALUES
(15, 'jwt-auth-client_secret_jwt', 'c8e9eed0-e6e4-4a66-b16e-6f37096356a7', 'JWT Bearer Auth Client (client_secret_jwt)',
- false, null, 3600, 600, true, 'SECRET_JWT', false, 'HS256', null, true),
+ false, null, 3600, 600, true, 'SECRET_JWT', false, 'HS256', null),
(16, 'jwt-auth-private_key_jwt', 'secret', 'JWT Bearer Auth Client (private_key_jwt)',
false, null, 3600, 600, true,'PRIVATE_KEY', false, 'RS256',
- '{"keys":[{"kty":"RSA","e":"AQAB","kid":"rsa1","n":"1y1CP181zqPNPlV1JDM7Xv0QnGswhSTHe8_XPZHxDTJkykpk_1BmgA3ovP62QRE2ORgsv5oSBI_Z_RaOc4Zx2FonjEJF2oBHtBjsAiF-pxGkM5ZPjFNgFTGp1yUUBjFDcEeIGCwPEyYSt93sQIP_0DRbViMUnpyn3xgM_a1dO5brEWR2n1Uqff1yA5NXfLS03qpl2dpH4HFY5-Zs4bvtJykpAOhoHuIQbz-hmxb9MZ3uTAwsx2HiyEJtz-suyTBHO3BM2o8UcCeyfa34ShPB8i86-sf78fOk2KeRIW1Bju3ANmdV3sxL0j29cesxKCZ06u2ZiGR3Srbft8EdLPzf-w"}]}', true);
+ '{"keys":[{"kty":"RSA","e":"AQAB","kid":"rsa1","n":"1y1CP181zqPNPlV1JDM7Xv0QnGswhSTHe8_XPZHxDTJkykpk_1BmgA3ovP62QRE2ORgsv5oSBI_Z_RaOc4Zx2FonjEJF2oBHtBjsAiF-pxGkM5ZPjFNgFTGp1yUUBjFDcEeIGCwPEyYSt93sQIP_0DRbViMUnpyn3xgM_a1dO5brEWR2n1Uqff1yA5NXfLS03qpl2dpH4HFY5-Zs4bvtJykpAOhoHuIQbz-hmxb9MZ3uTAwsx2HiyEJtz-suyTBHO3BM2o8UcCeyfa34ShPB8i86-sf78fOk2KeRIW1Bju3ANmdV3sxL0j29cesxKCZ06u2ZiGR3Srbft8EdLPzf-w"}]}');
INSERT INTO client_scope (owner_id, scope) VALUES
(1, 'openid'),
@@ -145,7 +145,8 @@ INSERT INTO client_redirect_uri (owner_id, redirect_uri) VALUES
(3, 'http://localhost:4000/callback'),
(4, 'http://localhost:5000/callback'),
(11, 'http://localhost:1234/callback'),
- (13, 'http://localhost:9876/implicit');
+ (13, 'http://localhost:9876/implicit'),
+ (18, 'https://iam.local.io/iam-test-client/openid_connect_login');
INSERT INTO client_grant_type (owner_id, grant_type) VALUES
(1, 'authorization_code'),
@@ -188,6 +189,7 @@ INSERT INTO client_grant_type (owner_id, grant_type) VALUES
(19, 'client_credentials');
INSERT INTO client_contact (owner_id, contact) VALUES
+ (1, 'admin@example.com'),
(12, 'test@example.com');
INSERT INTO iam_user_info(ID, GIVENNAME, FAMILYNAME, EMAIL, EMAILVERIFIED, BIRTHDATE, GENDER, NICKNAME) VALUES
@@ -242,9 +244,8 @@ INSERT INTO iam_account_group(account_id, group_id) VALUES
(2,2);
INSERT INTO iam_account_authority(account_id, authority_id) VALUES
-(2,2);
-
-
+(2,2),
+(1000, 2);
-- Other test groups
INSERT INTO iam_group(id, name, uuid, description, creationtime, lastupdatetime) VALUES
diff --git a/iam-test-client/pom.xml b/iam-test-client/pom.xml
index 80001a949..4d805c577 100644
--- a/iam-test-client/pom.xml
+++ b/iam-test-client/pom.xml
@@ -5,7 +5,7 @@
it.infn.mw.iam-parentiam-parent
- 1.10.2
+ 1.11.0it.infn.mw.iam-test-client
diff --git a/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java b/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java
index f64188517..cf913eaab 100644
--- a/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java
+++ b/iam-test-client/src/main/java/it/infn/mw/tc/IamAuthRequestOptionsService.java
@@ -21,7 +21,6 @@ public class IamAuthRequestOptionsService implements AuthRequestOptionsService {
IamClientApplicationProperties properties;
-
public IamAuthRequestOptionsService(IamClientApplicationProperties properties) {
this.properties = properties;
}
diff --git a/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java b/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java
index eaf0bcd41..26f9557f9 100644
--- a/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java
+++ b/iam-test-client/src/main/java/it/infn/mw/tc/IamTestClientApplication.java
@@ -80,7 +80,6 @@ public void commence(HttpServletRequest request, HttpServletResponse response,
}
-
@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
diff --git a/iam-test-client/src/main/resources/templates/index.html b/iam-test-client/src/main/resources/templates/index.html
index 5882f73cd..2c830de3a 100644
--- a/iam-test-client/src/main/resources/templates/index.html
+++ b/iam-test-client/src/main/resources/templates/index.html
@@ -138,7 +138,7 @@
INDIGO IAM Test Client Application
You're now logged in as: {{home.user}}
-
+
This application has received the following information: