package eu.eudat.service.user; import com.fasterxml.jackson.core.JsonProcessingException; import eu.eudat.authorization.AuthorizationFlags; import eu.eudat.authorization.OwnedResource; import eu.eudat.authorization.Permission; import eu.eudat.commons.JsonHandlingService; import eu.eudat.commons.XmlHandlingService; import eu.eudat.commons.enums.ActionConfirmationStatus; import eu.eudat.commons.enums.ActionConfirmationType; import eu.eudat.commons.enums.ContactInfoType; import eu.eudat.commons.enums.IsActive; import eu.eudat.commons.enums.notification.NotificationContactType; import eu.eudat.commons.scope.user.UserScope; import eu.eudat.commons.types.actionconfirmation.EmailConfirmationEntity; import eu.eudat.commons.types.notification.*; import eu.eudat.commons.types.user.AdditionalInfoEntity; import eu.eudat.configurations.notification.NotificationProperties; import eu.eudat.convention.ConventionService; import eu.eudat.data.*; import eu.eudat.errorcode.ErrorThesaurusProperties; import eu.eudat.event.UserTouchedEvent; import eu.eudat.event.EventBroker; import eu.eudat.integrationevent.outbox.notification.NotificationIntegrationEvent; import eu.eudat.integrationevent.outbox.notification.NotificationIntegrationEventHandler; import eu.eudat.model.User; import eu.eudat.model.UserContactInfo; import eu.eudat.model.builder.UserBuilder; import eu.eudat.model.deleter.UserCredentialDeleter; import eu.eudat.model.deleter.UserDeleter; import eu.eudat.model.deleter.UserRoleDeleter; import eu.eudat.model.persist.*; import eu.eudat.model.persist.actionconfirmation.EmailConfirmationPersist; import eu.eudat.query.*; import eu.eudat.service.actionconfirmation.ActionConfirmationService; import eu.eudat.service.keycloak.KeycloakRole; import eu.eudat.service.keycloak.KeycloakService; import gr.cite.commons.web.authz.service.AuthorizationService; import gr.cite.tools.data.builder.BuilderFactory; import gr.cite.tools.data.deleter.DeleterFactory; import gr.cite.tools.data.query.QueryFactory; import gr.cite.tools.exception.MyApplicationException; import gr.cite.tools.exception.MyForbiddenException; import gr.cite.tools.exception.MyNotFoundException; import gr.cite.tools.exception.MyValidationException; import gr.cite.tools.fieldset.BaseFieldSet; import gr.cite.tools.fieldset.FieldSet; import gr.cite.tools.logging.LoggerService; import gr.cite.tools.logging.MapLogEntry; import jakarta.persistence.EntityManager; import jakarta.xml.bind.JAXBException; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.QuoteMode; import org.jetbrains.annotations.NotNull; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Service; import javax.management.InvalidApplicationException; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; @Service public class UserServiceImpl implements UserService { private static final LoggerService logger = new LoggerService(LoggerFactory.getLogger(UserServiceImpl.class)); private final EntityManager entityManager; private final AuthorizationService authorizationService; private final DeleterFactory deleterFactory; private final BuilderFactory builderFactory; private final ConventionService conventionService; private final ErrorThesaurusProperties errors; private final MessageSource messageSource; private final EventBroker eventBroker; private final JsonHandlingService jsonHandlingService; private final XmlHandlingService xmlHandlingService; private final QueryFactory queryFactory; private final UserScope userScope; private final KeycloakService keycloakService; private final ActionConfirmationService actionConfirmationService; private final NotificationProperties notificationProperties; private final NotificationIntegrationEventHandler eventHandler; @Autowired public UserServiceImpl( EntityManager entityManager, AuthorizationService authorizationService, DeleterFactory deleterFactory, BuilderFactory builderFactory, ConventionService conventionService, ErrorThesaurusProperties errors, MessageSource messageSource, EventBroker eventBroker, JsonHandlingService jsonHandlingService, XmlHandlingService xmlHandlingService, QueryFactory queryFactory, UserScope userScope, KeycloakService keycloakService, ActionConfirmationService actionConfirmationService, NotificationProperties notificationProperties, NotificationIntegrationEventHandler eventHandler) { this.entityManager = entityManager; this.authorizationService = authorizationService; this.deleterFactory = deleterFactory; this.builderFactory = builderFactory; this.conventionService = conventionService; this.errors = errors; this.messageSource = messageSource; this.eventBroker = eventBroker; this.jsonHandlingService = jsonHandlingService; this.xmlHandlingService = xmlHandlingService; this.queryFactory = queryFactory; this.userScope = userScope; this.keycloakService = keycloakService; this.actionConfirmationService = actionConfirmationService; this.notificationProperties = notificationProperties; this.eventHandler = eventHandler; } //region persist @Override public User persist(UserPersist model, FieldSet fields) throws MyForbiddenException, MyValidationException, MyApplicationException, MyNotFoundException, InvalidApplicationException, JsonProcessingException { logger.debug(new MapLogEntry("persisting data User").And("model", model).And("fields", fields)); this.authorizationService.authorizeAtLeastOneForce(model.getId() != null ? List.of(new OwnedResource(model.getId())) : null, Permission.EditUser); Boolean isUpdate = this.conventionService.isValidGuid(model.getId()); UserEntity data; if (isUpdate) { data = this.entityManager.find(UserEntity.class, model.getId()); if (data == null) throw new MyNotFoundException(messageSource.getMessage("General_ItemNotFound", new Object[]{model.getId(), User.class.getSimpleName()}, LocaleContextHolder.getLocale())); if (!this.conventionService.hashValue(data.getUpdatedAt()).equals(model.getHash())) throw new MyValidationException(this.errors.getHashConflict().getCode(), this.errors.getHashConflict().getMessage()); } else { data = new UserEntity(); data.setId(UUID.randomUUID()); data.setIsActive(IsActive.Active); data.setCreatedAt(Instant.now()); } data.setAdditionalInfo(this.jsonHandlingService.toJson(this.buildAdditionalInfoEntity(model.getAdditionalInfo()))); data.setName(model.getName()); data.setUpdatedAt(Instant.now()); if (isUpdate) this.entityManager.merge(data); else this.entityManager.persist(data); this.entityManager.flush(); this.eventBroker.emit(new UserTouchedEvent(data.getId())); return this.builderFactory.builder(UserBuilder.class).authorize(AuthorizationFlags.OwnerOrDmpAssociatedOrPermissionOrPublic).build(BaseFieldSet.build(fields, User._id), data); } private @NotNull AdditionalInfoEntity buildAdditionalInfoEntity(UserAdditionalInfoPersist persist){ AdditionalInfoEntity data = new AdditionalInfoEntity(); if (persist == null) return data; data.setOrganizationId(persist.getOrganizationId()); data.setRoleOrganization(persist.getRoleOrganization()); data.setCulture(persist.getCulture()); data.setTimezone(persist.getTimezone()); data.setLanguage(persist.getLanguage()); data.setAvatarUrl(persist.getAvatarUrl()); return data; } //endregion //region delete @Override public void deleteAndSave(UUID id) throws MyForbiddenException, InvalidApplicationException { logger.debug("deleting User: {}", id); this.authorizationService.authorizeForce(Permission.DeleteUser); this.deleterFactory.deleter(UserDeleter.class).deleteAndSaveByIds(List.of(id)); } //endregion //region export @Override public byte[] exportCsv() throws IOException { this.authorizationService.authorizeForce(Permission.ExportUsers); FieldSet fieldSet = new BaseFieldSet().ensure(User._id).ensure(User._name).ensure(User._contacts + "." + UserContactInfo._value).ensure(User._contacts + "." + UserContactInfo._type); List users = this.builderFactory.builder(UserBuilder.class).build(fieldSet, this.queryFactory.query(UserQuery.class).collectAs(fieldSet)); final ByteArrayOutputStream out = new ByteArrayOutputStream(); final CSVFormat format = CSVFormat.DEFAULT.withHeader("User Id", "User Name", "User Email").withQuoteMode(QuoteMode.NON_NUMERIC); final CSVPrinter csvPrinter = new CSVPrinter(new PrintWriter(out), format); for (User user : users) { csvPrinter.printRecord(user.getId(), user.getName(), (user.getContacts() != null ? String.join(" ", user.getContacts().stream().filter(x-> ContactInfoType.Email.equals(x.getType())).map(UserContactInfo::getValue).toList()) : "")); } csvPrinter.flush(); return out.toByteArray(); } //endregion @Override public User patchRoles(UserRolePatchPersist model, FieldSet fields) throws InvalidApplicationException { logger.debug(new MapLogEntry("persisting data UserRole").And("model", model).And("fields", fields)); this.authorizationService.authorizeForce(Permission.EditUser); UserEntity data = this.entityManager.find(UserEntity.class, model.getId()); if (data == null) throw new MyNotFoundException(messageSource.getMessage("General_ItemNotFound", new Object[]{model.getId(), User.class.getSimpleName()}, LocaleContextHolder.getLocale())); if (!this.conventionService.hashValue(data.getUpdatedAt()).equals(model.getHash())) throw new MyValidationException(this.errors.getHashConflict().getCode(), this.errors.getHashConflict().getMessage()); List userCredentials = this.queryFactory.query(UserCredentialQuery.class).userIds(data.getId()).collect(); if (userCredentials.isEmpty()) throw new MyApplicationException("Currently cannot update roles for this user"); if (userCredentials.getFirst().getExternalId() == null) throw new MyApplicationException("Currently cannot update roles for this user"); UUID subjectId = UUID.fromString(userCredentials.getFirst().getExternalId()); List existingItems = this.queryFactory.query(UserRoleQuery.class).userIds(data.getId()).collect(); List foundIds = new ArrayList<>(); for (String roleName : model.getRoles().stream().filter(x-> x != null && !x.isBlank()).distinct().toList()) { UserRoleEntity item = existingItems.stream().filter(x-> x.getRole().equals(roleName)).findFirst().orElse(null); if (item == null) { item = new UserRoleEntity(); item.setId(UUID.randomUUID()); item.setUserId(data.getId()); item.setRole(roleName); item.setCreatedAt(Instant.now()); this.entityManager.persist(item); this.keycloakService.addUserToGroup(subjectId, KeycloakRole.valueOf(roleName)); } foundIds.add(item.getId()); } this.entityManager.flush(); List toDelete = existingItems.stream().filter(x-> foundIds.stream().noneMatch(y-> y.equals(x.getId()))).collect(Collectors.toList()); toDelete.forEach(x -> this.keycloakService.removeUserFromGroup(subjectId, KeycloakRole.valueOf(x.getRole()))); this.deleterFactory.deleter(UserRoleDeleter.class).deleteAndSave(toDelete); this.entityManager.flush(); this.eventBroker.emit(new UserTouchedEvent(data.getId())); return this.builderFactory.builder(UserBuilder.class).authorize(AuthorizationFlags.OwnerOrDmpAssociatedOrPermissionOrPublic).build(BaseFieldSet.build(fields, User._id), data); } //region mine @Override public void updateLanguageMine(String language) throws JsonProcessingException { logger.debug(new MapLogEntry("persisting User language").And("language", language)); UUID userId = this.userScope.getUserIdSafe(); if (userId == null) throw new MyForbiddenException(this.errors.getForbidden().getCode(), this.errors.getForbidden().getMessage()); UserEntity data = this.entityManager.find(UserEntity.class, userId); if (data == null) throw new MyNotFoundException(messageSource.getMessage("General_ItemNotFound", new Object[]{userId, User.class.getSimpleName()}, LocaleContextHolder.getLocale())); AdditionalInfoEntity additionalInfoEntity = this.jsonHandlingService.fromJsonSafe(AdditionalInfoEntity.class, data.getAdditionalInfo()); if (additionalInfoEntity == null) additionalInfoEntity = new AdditionalInfoEntity(); additionalInfoEntity.setLanguage(language); data.setAdditionalInfo(this.jsonHandlingService.toJson(additionalInfoEntity)); data.setUpdatedAt(Instant.now()); this.entityManager.merge(data); this.entityManager.flush(); this.eventBroker.emit(new UserTouchedEvent(data.getId())); } @Override public void updateTimezoneMine(String timezone) throws JsonProcessingException { logger.debug(new MapLogEntry("persisting User timezone").And("timezone", timezone)); UUID userId = this.userScope.getUserIdSafe(); if (userId == null) throw new MyForbiddenException(this.errors.getForbidden().getCode(), this.errors.getForbidden().getMessage()); UserEntity data = this.entityManager.find(UserEntity.class, userId); if (data == null) throw new MyNotFoundException(messageSource.getMessage("General_ItemNotFound", new Object[]{userId, User.class.getSimpleName()}, LocaleContextHolder.getLocale())); AdditionalInfoEntity additionalInfoEntity = this.jsonHandlingService.fromJsonSafe(AdditionalInfoEntity.class, data.getAdditionalInfo()); if (additionalInfoEntity == null) additionalInfoEntity = new AdditionalInfoEntity(); additionalInfoEntity.setTimezone(timezone); data.setAdditionalInfo(this.jsonHandlingService.toJson(additionalInfoEntity)); data.setUpdatedAt(Instant.now()); this.entityManager.merge(data); this.entityManager.flush(); this.eventBroker.emit(new UserTouchedEvent(data.getId())); } @Override public void updateCultureMine(String culture) throws JsonProcessingException { logger.debug(new MapLogEntry("persisting User culture").And("culture", culture)); UUID userId = this.userScope.getUserIdSafe(); if (userId == null) throw new MyForbiddenException(this.errors.getForbidden().getCode(), this.errors.getForbidden().getMessage()); UserEntity data = this.entityManager.find(UserEntity.class, userId); if (data == null) throw new MyNotFoundException(messageSource.getMessage("General_ItemNotFound", new Object[]{userId, User.class.getSimpleName()}, LocaleContextHolder.getLocale())); AdditionalInfoEntity additionalInfoEntity = this.jsonHandlingService.fromJsonSafe(AdditionalInfoEntity.class, data.getAdditionalInfo()); if (additionalInfoEntity == null) additionalInfoEntity = new AdditionalInfoEntity(); additionalInfoEntity.setCulture(culture); data.setAdditionalInfo(this.jsonHandlingService.toJson(additionalInfoEntity)); data.setUpdatedAt(Instant.now()); this.entityManager.merge(data); this.entityManager.flush(); this.eventBroker.emit(new UserTouchedEvent(data.getId())); } //endregion //notifications public void sendMergeAccountConfirmation(String email) throws InvalidApplicationException, JAXBException { ActionConfirmationPersist persist = new ActionConfirmationPersist(); persist.setType(ActionConfirmationType.MergeAccount); persist.setStatus(ActionConfirmationStatus.Requested); persist.setToken(UUID.randomUUID().toString()); persist.setEmailConfirmation(new EmailConfirmationPersist(email)); // persist.setCreatedById(this.userScope.getUserIdSafe()); TODO persist.setCreatedById(UUID.fromString("2c447092-ae88-40ab-ae7d-43b80b373a5f")); persist.setExpiresAt(Instant.now().plusSeconds(Long.parseLong(this.notificationProperties.getEmailExpirationTimeSeconds()))); this.actionConfirmationService.persist(persist, null); NotificationIntegrationEvent event = new NotificationIntegrationEvent(); event.setUserId(UUID.fromString("2c447092-ae88-40ab-ae7d-43b80b373a5f")); List contactPairs = new ArrayList<>(); contactPairs.add(new ContactPair(ContactInfoType.Email, email)); NotificationContactData contactData = new NotificationContactData(contactPairs, null, null); event.setContactHint(jsonHandlingService.toJsonSafe(contactData)); event.setContactTypeHint(NotificationContactType.EMAIL); event.setNotificationType(UUID.fromString(notificationProperties.getMergeAccountConfirmation())); NotificationFieldData data = new NotificationFieldData(); List fieldInfoList = new ArrayList<>(); fieldInfoList.add(new FieldInfo("{userName}", DataType.String, this.queryFactory.query(UserQuery.class).ids(UUID.fromString("2c447092-ae88-40ab-ae7d-43b80b373a5f")).first().getName())); fieldInfoList.add(new FieldInfo("{confirmationToken}", DataType.String, persist.getToken())); fieldInfoList.add(new FieldInfo("{expiration_time}", DataType.String, this.secondsToTime(Integer.parseInt(this.notificationProperties.getEmailExpirationTimeSeconds())))); data.setFields(fieldInfoList); event.setData(jsonHandlingService.toJsonSafe(data)); eventHandler.handle(event); } public void sendRemoveCredentialConfirmation(String email) throws InvalidApplicationException, JAXBException { ActionConfirmationPersist persist = new ActionConfirmationPersist(); persist.setType(ActionConfirmationType.RemoveCredential); persist.setStatus(ActionConfirmationStatus.Requested); persist.setToken(UUID.randomUUID().toString()); UserContactInfoEntity userContactInfo = this.queryFactory.query(UserContactInfoQuery.class).types(ContactInfoType.Email).userIds(UUID.fromString("d107dbad-c67f-418d-a930-f928a690dbfc")).values(email).first(); if(userContactInfo == null){ throw new InvalidApplicationException("Email does not exist in this user!"); } UserCredentialQuery query = this.queryFactory.query(UserCredentialQuery.class).userIds(UUID.fromString("d107dbad-c67f-418d-a930-f928a690dbfc")); if (query == null || query.count() == 0){ throw new InvalidApplicationException("This user don't have credential!"); } persist.setEmailConfirmation(new EmailConfirmationPersist(email)); // persist.setCreatedById(this.userScope.getUserIdSafe()); TODO persist.setCreatedById(UUID.fromString("d107dbad-c67f-418d-a930-f928a690dbfc")); persist.setExpiresAt(Instant.now().plusSeconds(Long.parseLong(this.notificationProperties.getEmailExpirationTimeSeconds()))); this.actionConfirmationService.persist(persist, null); NotificationIntegrationEvent event = new NotificationIntegrationEvent(); event.setUserId(UUID.fromString("d107dbad-c67f-418d-a930-f928a690dbfc")); List contactPairs = new ArrayList<>(); contactPairs.add(new ContactPair(ContactInfoType.Email, email)); NotificationContactData contactData = new NotificationContactData(contactPairs, null, null); event.setContactHint(jsonHandlingService.toJsonSafe(contactData)); event.setContactTypeHint(NotificationContactType.EMAIL); event.setNotificationType(UUID.fromString(notificationProperties.getRemoveCredentialConfirmation())); NotificationFieldData data = new NotificationFieldData(); List fieldInfoList = new ArrayList<>(); fieldInfoList.add(new FieldInfo("{confirmationToken}", DataType.String, persist.getToken())); fieldInfoList.add(new FieldInfo("{email}", DataType.String, email)); fieldInfoList.add(new FieldInfo("{expiration_time}", DataType.String, this.secondsToTime(Integer.parseInt(this.notificationProperties.getEmailExpirationTimeSeconds())))); data.setFields(fieldInfoList); event.setData(jsonHandlingService.toJsonSafe(data)); eventHandler.handle(event); } private String secondsToTime(int seconds) { int sec = seconds % 60; int hour = seconds / 60; int min = hour % 60; hour = hour / 60; return (hour + ":" + min + ":" + sec); } public void confirmMergeAccount(String token) throws InvalidApplicationException { ActionConfirmationEntity action = this.queryFactory.query(ActionConfirmationQuery.class).tokens(token).types(ActionConfirmationType.MergeAccount).isActive(IsActive.Active).first(); this.checkActionState(action); EmailConfirmationEntity emailConfirmation = this.xmlHandlingService.fromXmlSafe(EmailConfirmationEntity.class, action.getData()); action.setStatus(ActionConfirmationStatus.Accepted); //TODO merge this.entityManager.merge(action); } public void confirmRemoveCredential(String token) throws InvalidApplicationException { ActionConfirmationEntity action = this.queryFactory.query(ActionConfirmationQuery.class).tokens(token).types(ActionConfirmationType.RemoveCredential).isActive(IsActive.Active).first(); this.checkActionState(action); // EmailConfirmationEntity emailConfirmation = this.xmlHandlingService.fromXmlSafe(EmailConfirmationEntity.class, action.getData()); UserCredentialEntity userCredential = this.queryFactory.query(UserCredentialQuery.class).userIds(UUID.fromString("d107dbad-c67f-418d-a930-f928a690dbfc")).first(); //ToDO if (userCredential == null){ throw new InvalidApplicationException("This user does not have credential"); } this.deleterFactory.deleter(UserCredentialDeleter.class).deleteAndSaveByIds(List.of(userCredential.getId())); action.setStatus(ActionConfirmationStatus.Accepted); this.entityManager.merge(action); } private void checkActionState(ActionConfirmationEntity action) throws InvalidApplicationException { if (action == null){ throw new InvalidApplicationException("Token does not exist!"); } if (action.getStatus().equals(ActionConfirmationStatus.Accepted)){ throw new InvalidApplicationException("Account is already confirmed!"); } if (action.getExpiresAt().compareTo(Instant.now()) < 0){ throw new InvalidApplicationException("Token has expired!"); } } }