DomainUserRepositoryImpl.java

/*
 * Copyright 2019 the original author or authors.
 *
 * 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 org.bremersee.dccon.repository;

import java.awt.Dimension;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import javax.imageio.ImageIO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.bremersee.data.ldaptive.AbstractLdaptiveErrorHandler;
import org.bremersee.data.ldaptive.LdaptiveEntryMapper;
import org.bremersee.data.ldaptive.LdaptiveException;
import org.bremersee.data.ldaptive.LdaptiveTemplate;
import org.bremersee.dccon.config.DomainControllerProperties;
import org.bremersee.dccon.model.AvatarDefault;
import org.bremersee.dccon.model.DomainUser;
import org.bremersee.dccon.model.PasswordComplexity;
import org.bremersee.dccon.model.PasswordInformation;
import org.bremersee.dccon.repository.cli.CommandExecutor;
import org.bremersee.dccon.repository.cli.CommandExecutorResponse;
import org.bremersee.dccon.repository.cli.CommandExecutorResponseValidator;
import org.bremersee.dccon.repository.img.ImageScaler;
import org.bremersee.dccon.repository.ldap.DomainUserLdapConstants;
import org.bremersee.dccon.repository.ldap.DomainUserLdapMapper;
import org.bremersee.exception.ServiceException;
import org.ldaptive.AttributeModification;
import org.ldaptive.AttributeModificationType;
import org.ldaptive.LdapAttribute;
import org.ldaptive.LdapException;
import org.ldaptive.ModifyRequest;
import org.ldaptive.ResultCode;
import org.ldaptive.SearchFilter;
import org.ldaptive.SearchRequest;
import org.ldaptive.io.ByteArrayValueTranscoder;
import org.ldaptive.io.StringValueTranscoder;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;

/**
 * The domain user repository.
 *
 * @author Christian Bremer
 */
@Profile("ldap")
@Component("domainUserRepository")
@Slf4j
public class DomainUserRepositoryImpl extends AbstractRepository implements DomainUserRepository {

  private static final StringValueTranscoder STRING_VALUE_TRANSCODER = new StringValueTranscoder();

  private static ByteArrayValueTranscoder BYTE_ARRAY_VALUE_TRANSCODER
      = new ByteArrayValueTranscoder();

  private static final ResourceLoader RESOURCE_LOADER = new DefaultResourceLoader();

  private static final String NO_EMAIL_AVATAR = "classpath:mp.jpg";

  private final DomainRepository domainRepository;

  private final DomainGroupRepository domainGroupRepository;

  private LdaptiveEntryMapper<DomainUser> domainUserLdapMapper;

  /**
   * Instantiates a new domain user repository.
   *
   * @param properties the properties
   * @param ldapTemplateProvider the ldap template provider
   * @param domainRepository the domain repository
   * @param domainGroupRepository the domain group repository
   */
  public DomainUserRepositoryImpl(
      final DomainControllerProperties properties,
      final ObjectProvider<LdaptiveTemplate> ldapTemplateProvider,
      final DomainRepository domainRepository,
      final DomainGroupRepository domainGroupRepository) {
    super(properties, ldapTemplateProvider.getIfAvailable());
    this.domainUserLdapMapper = new DomainUserLdapMapper(properties);
    this.domainRepository = domainRepository;
    this.domainGroupRepository = domainGroupRepository;
  }

  private Pattern getPasswordPattern() {
    final PasswordInformation info = domainRepository.getPasswordInformation();
    final int minLength = info.getMinimumPasswordLength() != null
        ? info.getMinimumPasswordLength()
        : 7;
    final String regex;
    if (PasswordComplexity.OFF == info.getPasswordComplexity()) {
      regex = DomainControllerProperties.getSimplePasswordRegex(minLength);
    } else {
      regex = DomainControllerProperties.getComplexPasswordRegex(minLength);
    }
    return Pattern.compile(regex);
  }

  /**
   * Sets domain user ldap mapper.
   *
   * @param domainUserLdapMapper the domain user ldap mapper
   */
  @SuppressWarnings("unused")
  public void setDomainUserLdapMapper(
      final LdaptiveEntryMapper<DomainUser> domainUserLdapMapper) {
    if (domainUserLdapMapper != null) {
      this.domainUserLdapMapper = domainUserLdapMapper;
    }
  }

  @Override
  public Stream<DomainUser> findAll(final String query) {
    final SearchRequest searchRequest = new SearchRequest(
        getProperties().getUserBaseDn(),
        new SearchFilter(getProperties().getUserFindAllFilter()));
    searchRequest.setSearchScope(getProperties().getUserFindAllSearchScope());
    searchRequest.setBinaryAttributes(DomainUserLdapConstants.BINARY_ATTRIBUTES);
    if (query == null || query.trim().length() == 0) {
      return getLdapTemplate().findAll(searchRequest, domainUserLdapMapper);
    } else {
      return getLdapTemplate().findAll(searchRequest, domainUserLdapMapper)
          .filter(domainUser -> isQueryResult(domainUser, query.trim().toLowerCase()));
    }
  }

  /**
   * Is query result boolean.
   *
   * @param domainUser the domain user
   * @param query the query
   * @return the boolean
   */
  static boolean isQueryResult(final DomainUser domainUser, final String query) {
    return query != null && query.length() > 2 && domainUser != null
        && (contains(domainUser.getDisplayName(), query)
        || contains(domainUser.getUserName(), query)
        || contains(domainUser.getEmail(), query)
        || contains(domainUser.getMobile(), query)
        || contains(domainUser.getTelephoneNumber(), query)
        || contains(domainUser.getDescription(), query)
        || contains(domainUser.getFirstName(), query)
        || contains(domainUser.getLastName(), query)
        || contains(domainUser.getGroups(), query));
  }

  @Override
  public Optional<DomainUser> findOne(final String userName) {
    final SearchFilter searchFilter = new SearchFilter(getProperties().getUserFindOneFilter());
    searchFilter.setParameter(0, userName);
    final SearchRequest searchRequest = new SearchRequest(
        getProperties().getUserBaseDn(),
        searchFilter);
    searchRequest.setSearchScope(getProperties().getUserFindOneSearchScope());
    searchRequest.setBinaryAttributes(DomainUserLdapConstants.BINARY_ATTRIBUTES);
    searchRequest.setSizeLimit(1L);
    return getLdapTemplate().findOne(searchRequest, domainUserLdapMapper);
  }

  @Override
  public Optional<byte[]> findAvatar(
      final String userName,
      final AvatarDefault avatarDefault,
      final Integer size) {

    final int avatarSize = size == null || size < 1 || size > 2048 ? 80 : size;
    final SearchFilter searchFilter = new SearchFilter(getProperties().getUserFindOneFilter());
    searchFilter.setParameter(0, userName);
    final SearchRequest searchRequest = new SearchRequest(
        getProperties().getUserBaseDn(),
        searchFilter);
    searchRequest.setSearchScope(getProperties().getUserFindOneSearchScope());
    searchRequest.setBinaryAttributes(DomainUserLdapConstants.BINARY_ATTRIBUTES);
    searchRequest.setReturnAttributes(DomainUserLdapConstants.MAIL);
    searchRequest.setSizeLimit(1L);

    return getLdapTemplate().findOne(searchRequest)
        .map(ldapEntry -> {
          final byte[] avatar = LdaptiveEntryMapper.getAttributeValue(
              ldapEntry, DomainUserLdapConstants.JPEG_PHOTO, BYTE_ARRAY_VALUE_TRANSCODER, null);
          if (avatar != null && avatar.length > 0) {
            try {
              final BufferedImage img = ImageIO.read(new ByteArrayInputStream(avatar));
              final BufferedImage scaledImg = ImageScaler
                  .scaleImage(img, new Dimension(avatarSize, avatarSize));
              final ByteArrayOutputStream out = new ByteArrayOutputStream();
              ImageIO.write(scaledImg, "JPG", out);
              return out.toByteArray();

            } catch (IOException e) {
              log.error("msg=[Creating image from ldap attribute {} failed.]",
                  DomainUserLdapConstants.JPEG_PHOTO, e);
            }
          }
          final String mail = LdaptiveEntryMapper
              .getAttributeValue(ldapEntry, "mail", STRING_VALUE_TRANSCODER, null);
          return findAvatar(mail, getProperties().getGravatarUrl(), avatarDefault, avatarSize);
        });
  }

  /**
   * Find avatar byte [ ].
   *
   * @param mail the mail
   * @param avatarUrlTemplate the avatar url template
   * @param avatarDefault the avatar default
   * @param avatarSize the avatar size
   * @return the byte [ ]
   */
  static byte[] findAvatar(
      final String mail,
      final String avatarUrlTemplate,
      final AvatarDefault avatarDefault,
      final int avatarSize) {

    if (StringUtils.hasText(mail)) {
      final byte[] md5 = DigestUtils.md5Digest(mail.getBytes(StandardCharsets.UTF_8));
      final String hex = new String(Hex.encode(md5));
      final String defaultAvatar = avatarDefault != null
          ? avatarDefault.toString()
          : AvatarDefault.NOT_FOUND.toString();
      final String url = avatarUrlTemplate
          .replace("{hash}", hex)
          .replace("{default}", defaultAvatar)
          .replace("{size}", String.valueOf(avatarSize));
      try {
        return IOUtils.toByteArray(new URL(url));
      } catch (Exception e) {
        if (AvatarDefault.NOT_FOUND.toString().equalsIgnoreCase(defaultAvatar)) {
          return null;
        }
        log.error("msg=[Getting avatar failed. This should not happen.] url=[{}]",
            url, e);
      }
    } else if (AvatarDefault.NOT_FOUND == avatarDefault) {
      return null;
    }
    try {
      final BufferedImage img = ImageIO
          .read(RESOURCE_LOADER.getResource(NO_EMAIL_AVATAR).getInputStream());
      final BufferedImage scaledImg = ImageScaler
          .scaleImage(img, new Dimension(avatarSize, avatarSize));
      final ByteArrayOutputStream out = new ByteArrayOutputStream();
      ImageIO.write(scaledImg, "JPG", out);
      return out.toByteArray();

    } catch (IOException e) {
      final ServiceException se = ServiceException.internalServerError(
          "Getting default avatar for no email failed.",
          "org.bremersee:dc-con-app:1ec0dda8-7358-4e1c-a8f2-f4bd64e439f0",
          e);
      log.error("msg=[{}]", se.getMessage(), se);
      throw se;
    }

  }

  @Override
  public boolean exists(final String userName) {
    return getLdapTemplate()
        .exists(DomainUser.builder().userName(userName).build(), domainUserLdapMapper);
  }

  /**
   * Add user.
   *
   * @param domainUser the domain user
   */
  void doAdd(final DomainUser domainUser) {
    // Maybe I can add an user directly:
    // https://asadumar.wordpress.com/2013/02/28/create-user-password-in-active-directory-through-java-code/
    kinit();
    final List<String> commands = new ArrayList<>();
    sudo(commands);
    commands.add(getProperties().getSambaToolBinary());
    commands.add("user");
    commands.add("create");
    commands.add(domainUser.getUserName());
    commands.add("--random-password");
    commands.add("--use-username-as-cn");
    auth(commands);

    CommandExecutor.exec(
        commands,
        null,
        getProperties().getSambaToolExecDir(),
        (CommandExecutorResponseValidator) response -> {
          if (!exists(domainUser.getUserName())) {
            throw ServiceException.internalServerError("msg=[Saving user failed.] userName=["
                    + domainUser.getUserName() + "] "
                    + CommandExecutorResponse.toExceptionMessage(response),
                "org.bremersee:dc-con-app:216e1246-b464-48f1-ac88-20e8461dea1e");
          }
        });
  }

  @Override
  public DomainUser save(final DomainUser domainUser, final Boolean updateGroups) {
    if (!exists(domainUser.getUserName())) {
      if (StringUtils.hasText(domainUser.getPassword())
          && !getPasswordPattern().matcher(domainUser.getPassword()).matches()) {
        throw ServiceException.badRequest(
            "msg=[The password does not meet the complexity criteria!] userName=["
                + domainUser.getUserName() + "]",
            "check_password_restrictions");
      }
      doAdd(domainUser);
      if (StringUtils.hasText(domainUser.getPassword())) {
        savePassword(domainUser.getUserName(), domainUser.getPassword());
      }
    }

    DomainUser updatedDomainUser = getLdapTemplate().save(domainUser, domainUserLdapMapper);
    if (Boolean.TRUE.equals(updateGroups)) {
      final Set<String> oldGroups = new HashSet<>(updatedDomainUser.getGroups());
      final Set<String> newGroups = new HashSet<>(domainUser.getGroups());
      for (final String newGroup : newGroups) {
        if (!oldGroups.remove(newGroup)) {
          domainGroupRepository.findOne(newGroup).ifPresent(group -> {
            group.getMembers().add(domainUser.getUserName());
            domainGroupRepository.save(group);
          });
        }
      }
      for (final String oldGroup : oldGroups) {
        domainGroupRepository.findOne(oldGroup).ifPresent(group -> {
          group.getMembers().remove(domainUser.getUserName());
          domainGroupRepository.save(group);
        });
      }
      updatedDomainUser = updatedDomainUser.toBuilder()
          .groups(new ArrayList<>(newGroups))
          .build();
    } else {
      updatedDomainUser = updatedDomainUser.toBuilder()
          .groups(new ArrayList<>(domainUser.getGroups()))
          .build();
    }
    updatedDomainUser.getGroups().sort(String::compareToIgnoreCase);
    return updatedDomainUser;
  }

  @Override
  public void savePassword(final String userName, final String newPassword) {
    final String quotedPassword = "\"" + newPassword + "\"";
    final char[] unicodePwd = quotedPassword.toCharArray();
    final byte[] pwdArray = new byte[unicodePwd.length * 2];
    for (int i = 0; i < unicodePwd.length; i++) {
      pwdArray[i * 2 + 1] = (byte) (unicodePwd[i] >>> 8);
      pwdArray[i * 2] = (byte) (unicodePwd[i] & 0xff);
    }
    final LdapAttribute ldapAttribute = new LdapAttribute(true);
    ldapAttribute.setName("unicodePwd");
    ldapAttribute.addBinaryValue(pwdArray);
    final AttributeModification attributeModification = new AttributeModification();
    attributeModification.setAttributeModificationType(AttributeModificationType.REPLACE);
    attributeModification.setAttribute(ldapAttribute);
    final String dn = LdaptiveEntryMapper.createDn(
        getProperties().getUserRdn(),
        userName,
        getProperties().getUserBaseDn());
    final ModifyRequest modifyRequest = new ModifyRequest();
    modifyRequest.setDn(dn);
    modifyRequest.setAttributeModifications(attributeModification);
    getLdapTemplate()
        .clone(new AbstractLdaptiveErrorHandler() {
          @Override
          public LdaptiveException map(final LdapException ldapException) {
            final HttpStatus httpStatus;
            final String errorCode;
            if (ldapException.getResultCode() == ResultCode.CONSTRAINT_VIOLATION
                && ldapException.getMessage().contains("check_password_restrictions")) {
              httpStatus = HttpStatus.BAD_REQUEST;
              errorCode = "check_password_restrictions";
            } else {
              httpStatus = ldapException.getResultCode() == ResultCode.NO_SUCH_OBJECT
                  ? HttpStatus.NOT_FOUND
                  : HttpStatus.INTERNAL_SERVER_ERROR;
              errorCode = "org.bremersee.dc-con-app:a70939fb-2c94-412f-80c0-00a7d5dcf4a6";
            }
            return LdaptiveException.builder()
                .httpStatus(httpStatus.value())
                .errorCode(errorCode)
                .cause(ldapException)
                .build();
          }
        })
        .modify(modifyRequest);
  }

  @Override
  public boolean delete(final String userName) {

    if (exists(userName)) {
      doDelete(userName);
      return true;
    }
    return false;
  }

  /**
   * Delete user.
   *
   * @param userName the user name
   */
  void doDelete(final String userName) {
    kinit();
    final List<String> commands = new ArrayList<>();
    sudo(commands);
    commands.add(getProperties().getSambaToolBinary());
    commands.add("user");
    commands.add("delete");
    commands.add(userName);
    auth(commands);
    CommandExecutor.exec(
        commands,
        null,
        getProperties().getSambaToolExecDir(),
        (CommandExecutorResponseValidator) response -> {
          if (exists(userName)) {
            throw ServiceException.internalServerError(
                "msg=[Deleting user failed.] userName=[" + userName + "] "
                    + CommandExecutorResponse.toExceptionMessage(response));
          }
        });
  }
}