package at.asitplus.eidas.specific.modules.auth.eidas.v2.service;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import org.jetbrains.annotations.Nullable;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import com.google.common.collect.Streams;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.ErnpRestClient.ErnpRegisterResult;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.IErnpClient;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.zmr.IZmrClient;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.zmr.ZmrSoapClient.ZmrRegisterResult;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.controller.AdresssucheController.AdresssucheOutput;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.RegisterResult;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.exception.EidasSAuthenticationException;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.exception.WorkflowException;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.exception.ZmrCommunicationException;
import at.asitplus.eidas.specific.modules.auth.eidas.v2.handler.CountrySpecificDetailSearchProcessor;
import at.gv.bmi.namespace.zmr_su.zmr._20040201.PersonSuchenRequest;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service("registerSearchService")
public class RegisterSearchService {
private static final Object ZMR = "ZMR";
private static final Object ERNP = "ERnP";
private static final Object ERNP_KITT = "ERnP(ZMR-Kitt)";
private static final String LOG_MSG_RESULTS = "Matching operation: {} results: "
+ ZMR + ": {} | " + ERNP + ": {} | " + ERNP_KITT + ": {}";
private static final String LOG_MSG_RESULTS_CLEARING =
"Post-processing of register results find duplicated entries. "
+ "Remove {} entries from " + ERNP + " result.";
private static final String LOG_MSG_RESULTS_CLEARING_KITT =
"Post-processing of register results find duplicated entries. "
+ "Remove {} entries from " + ERNP_KITT + " result.";
private static final String LOG_MSG_KITT = "Matching operation kitts entry on: {}";
private final IZmrClient zmrClient;
private final IErnpClient ernpClient;
private final List handlers;
/**
* Service that combines ZMR and ERnP register search operations.
*
* @param handlers Available country-specific search processors
* @param zmrClient ZMR client
* @param ernpClient ERnP client
*/
public RegisterSearchService(List handlers, IZmrClient zmrClient,
IErnpClient ernpClient) {
this.zmrClient = zmrClient;
this.ernpClient = ernpClient;
this.handlers = handlers;
log.info("Init with #{} search services for country-specific details", handlers.size());
}
/**
* Search with Person Identifier (eIDAS Pseudonym) in ZMR and ERnP.
*
* @param eidasData Received eIDAS data
* @throws WorkflowException In case of a register interaction error
*/
@Nonnull
public RegisterStatusResults searchWithPersonIdentifier(SimpleEidasData eidasData)
throws WorkflowException {
return searchWithPersonIdentifier(null, eidasData);
}
/**
* Search with Person Identifier (eIDAS Pseudonym) in ZMR and ERnP.
*
* @param operationStatus Current register-operation status that contains processing informations
* @param eidasData Received eIDAS data
* @throws WorkflowException In case of a register interaction error
*/
@Nonnull
public RegisterStatusResults searchWithPersonIdentifier(@Nullable RegisterOperationStatus operationStatus,
@Nonnull SimpleEidasData eidasData) throws WorkflowException {
try {
final ZmrRegisterResult resultsZmr = zmrClient.searchWithPersonIdentifier(
operationStatus != null ? operationStatus.getZmrProcessId() : null,
eidasData.getPseudonym(), eidasData.getCitizenCountryCode());
final ErnpRegisterResult resultsErnp = ernpClient.searchWithPersonIdentifier(
eidasData.getPseudonym(), eidasData.getCitizenCountryCode());
log.info(LOG_MSG_RESULTS, "seachByPersonalId",
resultsZmr.getPersonResult().size(), resultsErnp.getPersonResult().size(),
resultsErnp.getZmrPersonResult().size());
return RegisterStatusResults.fromZmrAndErnp(operationStatus, resultsZmr, resultsErnp);
} catch (final EidasSAuthenticationException e) {
throw new WorkflowException("searchWithPersonalIdentifier", e.getMessage(),
!(e instanceof ZmrCommunicationException), e);
}
}
/**
* Search with MDS (Given Name, Family Name, Date of Birth) in ZMR and ERnP.
*
* @param operationStatus Current register-operation status that contains processing informations
* @param eidasData Received eIDAS data
* @throws WorkflowException In case of a register interaction error
*/
@Nonnull
public RegisterStatusResults searchWithMds(RegisterOperationStatus operationStatus, SimpleEidasData eidasData)
throws WorkflowException {
try {
final ZmrRegisterResult resultsZmr =
zmrClient.searchWithMds(operationStatus.getZmrProcessId(), eidasData.getGivenName(),
eidasData.getFamilyName(), eidasData.getDateOfBirth(), eidasData.getCitizenCountryCode());
final ErnpRegisterResult resultsErnp =
ernpClient.searchWithMds(eidasData.getGivenName(),
eidasData.getFamilyName(), eidasData.getDateOfBirth(), eidasData.getCitizenCountryCode());
log.info(LOG_MSG_RESULTS, "seachByMDS",
resultsZmr.getPersonResult().size(), resultsErnp.getPersonResult().size(),
resultsErnp.getZmrPersonResult().size());
return RegisterStatusResults.fromZmrAndErnp(operationStatus, resultsZmr, resultsErnp);
} catch (final EidasSAuthenticationException e) {
throw new WorkflowException("searchWithMDSOnly", e.getMessage(),
!(e instanceof ZmrCommunicationException), e);
}
}
/**
* Search with country-specific parameters based on information from available
* {@link CountrySpecificDetailSearchProcessor} implementations.
*
* @param operationStatus Current register-operation status that contains processing informations
* @param eidasData Receive eIDAS eID information
* @return Results from ZMR or ERnP search
* @throws WorkflowException In case of a register interaction error
*/
@Nonnull
public RegisterStatusResults searchWithCountrySpecifics(RegisterOperationStatus operationStatus,
SimpleEidasData eidasData) throws WorkflowException {
try {
@Nullable final CountrySpecificDetailSearchProcessor ccSpecificProcessor = findSpecificProcessor(eidasData);
if (ccSpecificProcessor != null) {
log.info("Selecting country-specific search processor: {}", ccSpecificProcessor.getName());
PersonSuchenRequest ccSpecificSearchReq = ccSpecificProcessor.generateSearchRequest(eidasData);
// search in ZMR
final ZmrRegisterResult resultsZmr =
zmrClient.searchCountrySpecific(operationStatus.getZmrProcessId(),
ccSpecificSearchReq, eidasData.getCitizenCountryCode());
//search in ERnP
ErnpRegisterResult resultErnp = ernpClient.searchCountrySpecific(
ccSpecificSearchReq, eidasData.getCitizenCountryCode());
log.info(LOG_MSG_RESULTS, "seachByCountrySpecifics",
resultsZmr.getPersonResult().size(), resultErnp.getPersonResult().size(),
resultErnp.getZmrPersonResult().size());
return RegisterStatusResults.fromZmrAndErnp(operationStatus, resultsZmr, resultErnp);
} else {
return RegisterStatusResults.fromEmpty(operationStatus);
}
} catch (final EidasSAuthenticationException e) {
throw new WorkflowException("searchWithCountrySpecifics", e.getMessage(),
!(e instanceof ZmrCommunicationException), e);
}
}
/**
* Search with residence infos.
*
* @param operationStatus Current register-operation status that contains processing informations
* @param eidasData Receive eIDAS eID information
* @param address Address information provided by user
* @return Results from ZMR or ERnP search
* @throws WorkflowException In case of a register interaction error
*/
public RegisterStatusResults searchWithResidence(RegisterOperationStatus operationStatus, SimpleEidasData eidasData,
AdresssucheOutput address) throws WorkflowException {
try {
final ZmrRegisterResult resultsZmr = zmrClient.searchWithResidenceData(
operationStatus.getZmrProcessId(), eidasData.getGivenName(), eidasData.getFamilyName(),
eidasData.getDateOfBirth(), eidasData.getCitizenCountryCode(), address);
/* ERnP search is not used here,
* because we only search for people with Austrian residence and they are in ZMR only
*/
log.info(LOG_MSG_RESULTS, "seachByResidence",
resultsZmr.getPersonResult().size(), 0, 0);
return RegisterStatusResults.fromZmr(operationStatus, resultsZmr);
} catch (final EidasSAuthenticationException e) {
throw new WorkflowException("searchWithResidenceInformation", e.getMessage(),
!(e instanceof ZmrCommunicationException), e);
}
}
/**
* Automatic process to fix the register entries.
* Called when the initial eIDAS authn leads to a match in a register.
*
* @param registerResult Result of last register search
* @param initialEidasData Received eidas data from initial authn
* @return
*/
@NonNull
public RegisterStatusResults step7aKittProcess(RegisterStatusResults registerResult,
SimpleEidasData initialEidasData) throws WorkflowException {
log.trace("Starting step7aKittProcess");
// check if only one single result was found
if (registerResult.getResultCount() != 1) {
throw new WorkflowException("step7aKittProcess", "getResultCount() != 1");
}
// perform updated operation in respect to register results
try {
if (registerResult.getResultsZmr().size() == 1) {
RegisterResult entryZmr = registerResult.getResultsZmr().get(0);
ZmrRegisterResult updateZmr = zmrClient
.update(registerResult.getOperationStatus().getZmrProcessId(), entryZmr, initialEidasData);
log.info(LOG_MSG_KITT, ZMR);
return RegisterStatusResults.fromZmr(registerResult.operationStatus, updateZmr);
} else {
RegisterResult entryErnp = registerResult.getResultsErnp().get(0);
ErnpRegisterResult updateErnp = ernpClient.update(entryErnp, initialEidasData);
log.info(LOG_MSG_KITT, ERNP);
return RegisterStatusResults.fromErnp(registerResult.operationStatus, updateErnp);
}
} catch (final EidasSAuthenticationException e) {
throw new WorkflowException("kittMatchedIdentitiess", e.getMessage(),
!(e instanceof ZmrCommunicationException), e);
}
}
/**
* Automatic process to fix the register entries.
* Called when the alternative eIDAS authn leads to a match in a register.
*
* This method perform two additional operations:
*
* - Use bPK to check if altSearchResult is part of initialSearchResult.
* - Update register entry twice, be using information from alternative authentication altEidasData
* and from initial authentication initialEidasData.
*
*
*
* @param initialSearchResult Register results from initial authentication
* @param initialEidasData Received eIDAS data from initial authentication
* @param altSearchResult Register results from alternative authentication
* @param altEidasData Received eIDAS data from alternative authentication
* @return
*/
public RegisterStatusResults step7bKittProcess(
RegisterStatusResults initialSearchResult, SimpleEidasData initialEidasData,
RegisterStatusResults altSearchResult, SimpleEidasData altEidasData) throws WorkflowException {
log.trace("Starting step7bKittProcess");
// check if alternative authentication ends in a single result
if (altSearchResult.getResultCount() != 1) {
throw new WorkflowException("step7bKittProcess", "getResultCount() != 1");
}
// check if alternative authentication result is part of initialSearchResults
if (!Streams.concat(initialSearchResult.getResultsZmr().stream(), initialSearchResult.getResultsErnp().stream())
.filter(el -> {
try {
return altSearchResult.getResult().getBpk().equals(el.getBpk());
} catch (WorkflowException e1) {
//can not appear because it's already validated above.
return false;
}
})
.findFirst()
.isPresent()) {
throw new WorkflowException("step7bKittProcess",
"Register result from alternativ authentication does not fit into intermediate state");
}
// perform KITT operations
try {
if (altSearchResult.getResultsZmr().size() == 1) {
RegisterResult entryZmr = altSearchResult.getResultsZmr().get(0);
// update ZMR entry by using eIDAS information from initial authentication
zmrClient.update(altSearchResult.getOperationStatus().getZmrProcessId(), entryZmr, initialEidasData);
// update ZMR entry by using eIDAS information from alternative authentication
ZmrRegisterResult updateAlt = zmrClient.update(
altSearchResult.getOperationStatus().getZmrProcessId(), entryZmr, altEidasData);
log.info(LOG_MSG_KITT, ZMR);
return RegisterStatusResults.fromZmr(altSearchResult.getOperationStatus(), updateAlt);
} else {
RegisterResult entryErnp = altSearchResult.getResultsErnp().get(0);
// update ZMR entry by using eIDAS information from initial authentication
ernpClient.update(entryErnp, initialEidasData);
// update ZMR entry by using eIDAS information from alternative authentication
ErnpRegisterResult updateAlt = ernpClient.update(entryErnp, altEidasData);
log.info(LOG_MSG_KITT, ERNP);
return RegisterStatusResults.fromErnp(altSearchResult.getOperationStatus(), updateAlt);
}
} catch (final EidasSAuthenticationException e) {
throw new WorkflowException("kittMatchedIdentitiess", e.getMessage(),
!(e instanceof ZmrCommunicationException), e);
}
}
@Nullable
private CountrySpecificDetailSearchProcessor findSpecificProcessor(SimpleEidasData eidasData) {
final String citizenCountry = eidasData.getCitizenCountryCode();
for (final CountrySpecificDetailSearchProcessor processor : handlers) {
if (processor.canHandle(citizenCountry, eidasData)) {
log.debug("Found suitable search handler for {} by using: {}", citizenCountry, processor.getName());
return processor;
}
}
return null;
}
/**
* Register releated information that are needed for any request.
*
* @author tlenz
*/
@AllArgsConstructor
@Getter
public static class RegisterOperationStatus implements Serializable {
private static final long serialVersionUID = -1037357883275379796L;
/**
* ZMR internal processId that is required for any further request in the same process.
*/
private BigInteger zmrProcessId;
/**
* Flag that indicates if ERnP entries by user decision is allowed.
*/
private final boolean allowErnpEntryByUser;
/**
* Build {@link RegisterOperationStatus} based on an existing status 'before'
* and an 'allowErnpEntry' flag of current process.
*
* @param before Status from process-step before
* @param allowErnpEntry does current result allow new ERnP entry by user
* @return
*/
public static RegisterOperationStatus buildFromExisting(@Nonnull RegisterOperationStatus before,
boolean allowErnpEntry) {
return new RegisterOperationStatus(before.getZmrProcessId(),
before.isAllowErnpEntryByUser() && allowErnpEntry);
}
}
/**
* Response container for {@link RegisterSearchService} that holds a set of {@link RegisterResult}.
*
* @author tlenz
*/
@Getter
@RequiredArgsConstructor
public static class RegisterStatusResults implements Serializable {
private static final long serialVersionUID = -2489125033838373511L;
/**
* Operation status for this result.
*/
private final RegisterOperationStatus operationStatus;
/**
* Current ZMR search result.
*/
private final List resultsZmr;
/**
* Current ERnP search result.
*/
private final List resultsErnp;
/**
* Get sum of ZMR and ERnP results.
*
* @return number of results
*/
public int getResultCount() {
return resultsZmr.size() + resultsErnp.size();
}
/**
* Verifies that there is only one match and returns the bpk.
*
* @return bpk bpk of the match
* @throws WorkflowException if multiple results have been found
*/
public String getBpk() throws WorkflowException {
if (getResultCount() != 1) {
throw new WorkflowException("readRegisterResults", "getResultCount() != 1");
}
return getResult().getBpk();
}
/**
* Returns the results, if there is exactly one, throws exception otherwise.
*
* @return The result
* @throws WorkflowException Results does not contain exactly one result
*/
public RegisterResult getResult() throws WorkflowException {
if (getResultCount() != 1) {
throw new WorkflowException("readRegisterResults", "getResultCount() != 1");
}
if (resultsZmr.size() == 1) {
return resultsZmr.get(0);
} else {
return resultsErnp.get(0);
}
}
static RegisterStatusResults fromZmr(RegisterOperationStatus status, ZmrRegisterResult result) {
return new RegisterStatusResults(RegisterOperationStatus.buildFromExisting(status, true),
result.getPersonResult(), Collections.emptyList());
}
static RegisterStatusResults fromZmrAndErnp(RegisterOperationStatus status,
ZmrRegisterResult result, ErnpRegisterResult resultErnp) {
/*
* Post-processing of ERnP entries to remove entities that included twice.
* In case of KITT on register side that KITTS an ERnP to ZMR entry,
* the same entity can part of both responses.
*/
Set existingZmrPersons = result.getPersonResult().stream()
.map(el -> el.getBpk())
.collect(Collectors.toSet());
// check active ERnP entries
List ernpCleared = resultErnp.getPersonResultStream()
.filter(el -> !existingZmrPersons.contains(el.getBpk()))
.collect(Collectors.toList());
if (ernpCleared.size() < resultErnp.getPersonResult().size()) {
log.warn(LOG_MSG_RESULTS_CLEARING, resultErnp.getPersonResult().size() - ernpCleared.size());
}
// check ERnP to ZMR kitt entries and join results from ZMR client
List zmrCleared = Streams.concat(
resultErnp.getZmrPersonResultStream()
.filter(el -> !existingZmrPersons.contains(el.getBpk())),
result.getPersonResult().stream())
.collect(Collectors.toList());
if (zmrCleared.size() < result.getPersonResult().size() + resultErnp.getZmrPersonResult().size()) {
log.info(LOG_MSG_RESULTS_CLEARING_KITT,
result.getPersonResult().size() + resultErnp.getZmrPersonResult().size() - zmrCleared.size());
}
return new RegisterStatusResults(
status != null
? RegisterOperationStatus.buildFromExisting(status, resultErnp.isAllowErnpEntryByUser())
: new RegisterOperationStatus(result.getProcessId(), resultErnp.isAllowErnpEntryByUser()),
zmrCleared, ernpCleared);
}
static RegisterStatusResults fromErnp(RegisterOperationStatus status, ErnpRegisterResult updateErnp) {
return new RegisterStatusResults(
RegisterOperationStatus.buildFromExisting(status, updateErnp.isAllowErnpEntryByUser()),
Collections.emptyList(), updateErnp.getPersonResult());
}
static RegisterStatusResults fromEmpty(RegisterOperationStatus status) {
return new RegisterStatusResults(
RegisterOperationStatus.buildFromExisting(status, true),
Collections.emptyList(), Collections.emptyList());
}
}
}