StudipUserProviderInstance.java

/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community 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://opensource.org/licenses/ecl2.txt
 *
 * 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.opencastproject.userdirectory.studip;

import org.opencastproject.security.api.CachingUserProviderMXBean;
import org.opencastproject.security.api.Group;
import org.opencastproject.security.api.JaxbOrganization;
import org.opencastproject.security.api.JaxbRole;
import org.opencastproject.security.api.JaxbUser;
import org.opencastproject.security.api.Organization;
import org.opencastproject.security.api.Role;
import org.opencastproject.security.api.RoleProvider;
import org.opencastproject.security.api.User;
import org.opencastproject.security.api.UserProvider;

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ExecutionError;
import com.google.common.util.concurrent.UncheckedExecutionException;

import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.management.ManagementFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

import javax.management.InstanceNotFoundException;
import javax.management.MBeanServer;
import javax.management.ObjectName;

/**
 * A UserProvider that reads user roles from Studip.
 */
public class StudipUserProviderInstance implements UserProvider, RoleProvider, CachingUserProviderMXBean {

  public static final String PROVIDER_NAME = "studip";

  private static final String OC_USERAGENT = "Opencast";
  private static final String STUDIP_GROUP = Group.ROLE_PREFIX + "STUDIP";

  /** The logger */
  private static final Logger logger = LoggerFactory.getLogger(StudipUserProviderInstance.class);

  /** The organization */
  private Organization organization = null;

  /** Total number of requests made to load users */
  private AtomicLong requests = null;

  /** The number of requests made to Studip */
  private AtomicLong studipLoads = null;

  /** A cache of users, which lightens the load on Studip */
  private LoadingCache<String, Object> cache = null;

  /** A token to store in the miss cache */
  protected Object nullToken = new Object();

  /** The URL of the Studip instance */
  private URI studipUrl;

  /** The URL of the Studip instance */
  private String studipToken = null;

  /**
   * Constructs an Studip user provider with the needed settings.
   *
   * @param pid
   *          the pid of this service
   * @param organization
   *          the organization
   * @param url
   *          the url of the Studip server
   * @param token
   *          the token to authenticate with
   * @param cacheSize
   *          the number of users to cache
   * @param cacheExpiration
   *          the number of minutes to cache users
   */
  public StudipUserProviderInstance(
      String pid,
      Organization organization,
      URI url,
      String token,

      int cacheSize,
      int cacheExpiration
  ) {

    this.organization = organization;
    this.studipUrl = url;
    this.studipToken = token;

    logger.info("Creating new StudipUserProviderInstance(pid={}, url={}, cacheSize={}, cacheExpiration={})",
                 pid, url, cacheSize, cacheExpiration);

    // Setup the caches
    cache = CacheBuilder.newBuilder().maximumSize(cacheSize).expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
        .build(new CacheLoader<String, Object>() {
          @Override
          public Object load(String id) throws Exception {
            User user = loadUserFromStudip(id);
            return user == null ? nullToken : user;
          }
        });

    registerMBean(pid);
  }

  @Override
  public String getName() {
    return PROVIDER_NAME;
  }

  /**
   * Registers an MXBean.
   */
  protected void registerMBean(String pid) {
    // register with jmx
    requests = new AtomicLong();
    studipLoads = new AtomicLong();
    try {
      ObjectName name;
      name = StudipUserProviderFactory.getObjectName(pid);
      Object mbean = this;
      MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
      try {
        mbs.unregisterMBean(name);
      } catch (InstanceNotFoundException e) {
        logger.debug("{} was not registered before", name);
      }
      mbs.registerMBean(mbean, name);
    } catch (Exception e) {
      logger.error("Unable to register {} as an mbean", this, e);
    }
  }

  // UserProvider methods

  /**
   * {@inheritDoc}
   * 
   * @see org.opencastproject.security.api.UserProvider#getOrganization()
   */
  @Override
  public String getOrganization() {
    return organization.getId();
  }

  /**
   * {@inheritDoc}
   * 
   * @see org.opencastproject.security.api.UserProvider#loadUser(java.lang.String)
   */
  @Override
  public User loadUser(String userName) {
    logger.debug("loaduser({})", userName);

    requests.incrementAndGet();
    try {
      Object user = cache.getUnchecked(userName);
      if (user == nullToken) {
        logger.debug("Returning null user from cache");
        return null;
      } else {
        logger.debug("Returning user {} from cache", userName);
        return (JaxbUser) user;
      }
    } catch (ExecutionError | UncheckedExecutionException e) {
      logger.warn("Exception while loading user {}", userName, e);
      return null;
    }
  }

  /**
   * Loads a user from Stud.IP.
   * 
   * @param userName
   *          the username
   * @return the user
   */
  protected User loadUserFromStudip(String userName) {
    if (cache == null) {
      throw new IllegalStateException("The Stud.IP user detail service has not yet been configured");
    }

    // Don't answer for admin, anonymous or empty user
    if ("admin".equals(userName) || "".equals(userName) || "anonymous".equals(userName)) {
      cache.put(userName, nullToken);
      logger.debug("we don't answer for {}", userName);
      return null;
    }

    logger.debug("In loadUserFromStudip, currently processing user : {}", userName);

    JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);

    // update cache statistics
    studipLoads.incrementAndGet();

    Thread currentThread = Thread.currentThread();
    ClassLoader originalClassloader = currentThread.getContextClassLoader();
    try {
      // Stud.IP userId (internal id), email address and display name
      JSONObject userJsonObj = getStudipUser(userName);
      if (userJsonObj == null) {
        return null;
      }

      Set<JaxbRole> roles = new HashSet<>();
      if (userJsonObj.containsKey("roles")) {
        JSONArray rolesArray = (JSONArray) userJsonObj.get("roles");
        for (Object r : rolesArray) {
          roles.add(new JaxbRole(r.toString(), jaxbOrganization, "Studip external role", Role.Type.EXTERNAL));
        }
      }

      // Group role for all Stud.IP users
      roles.add(new JaxbRole(STUDIP_GROUP, jaxbOrganization, "Studip Users", Role.Type.EXTERNAL_GROUP));
      logger.debug("Returning JaxbRoles: {}", roles);

      // Email address
      var email = Objects.toString(userJsonObj.get("email"), null);
      var name = Objects.toString(userJsonObj.get("fullname"), null);

      User user = new JaxbUser(userName, null, name, email, PROVIDER_NAME, jaxbOrganization, roles);

      cache.put(userName, user);
      logger.debug("Returning user {}", userName);

      return user;

    } catch (ParseException e) {
      logger.error("Exception while parsing response from provider for user {}", userName, e);
      return null;
    } catch (IOException e) {
      logger.error("Error requesting user data for user `{}`: {}", userName, e.getMessage());
      return null;
    } catch (URISyntaxException e) {
      logger.error("Misspelled URI", e);
      return null;
    } finally {
      currentThread.setContextClassLoader(originalClassloader);
    }
  }

  /**
   * Get the internal Stud.IP user Id for the supplied user. If the user exists, set the user's email address.
   * 
   * @param uid Identifier of the user to look for
   * @return JSON object containing user information
   */
  private JSONObject getStudipUser(String uid) throws URISyntaxException, IOException, ParseException {
    // Build URL
    var apiPath = new URIBuilder().setPathSegments("opencast", "user", uid).getPath();
    var url = new URIBuilder(studipUrl)
        .setPath(studipUrl.getPath().replaceAll("/*$", "") + apiPath)
        .addParameter("token", studipToken)
        .build();

    // Execute request
    HttpGet get = new HttpGet(url);
    get.setHeader("User-Agent", OC_USERAGENT);

    // Don't wait for responses indefinitely
    RequestConfig config = RequestConfig.custom()
        .setConnectTimeout(5000)
        .setSocketTimeout(10000).build();

    try (CloseableHttpClient client = HttpClientBuilder.create().setDefaultRequestConfig(config).build();) {
      try (CloseableHttpResponse resp = client.execute(get)) {
        var statusCode = resp.getStatusLine().getStatusCode();
        if (statusCode == 404) {
          // Stud.IP does not know about the user
          return null;
        } else if (statusCode / 100 != 2) {
          throw new IOException("HttpRequest unsuccessful, reason: " + resp.getStatusLine().getReasonPhrase());
        }

        // Parse response
        BufferedReader reader = new BufferedReader(new InputStreamReader(resp.getEntity().getContent()));
        JSONParser parser = new JSONParser();
        Object obj = parser.parse(reader);

        // Check for errors
        if (!(obj instanceof JSONObject)) {
          throw new IOException("StudIP responded in unexpected format");
        }

        JSONObject jObj = (JSONObject) obj;
        if (jObj.containsKey("errors")) {
          throw new IOException("Stud.IP returned an error: " + jObj.toJSONString());
        }

        return jObj;
      }
    }
  }

  /**
   * {@inheritDoc}
   * 
   * @see org.opencastproject.security.api.CachingUserProviderMXBean#getCacheHitRatio()
   */
  @Override
  public float getCacheHitRatio() {
    if (requests.get() == 0) {
      return 0;
    }
    return (float) (requests.get() - studipLoads.get()) / requests.get();
  }

  @Override
  public Iterator<User> findUsers(String query, int offset, int limit) {

    if (query == null) {
      throw new IllegalArgumentException("Query must be set");
    }

    if (query.endsWith("%")) {
      query = query.substring(0, query.length() - 1);
    }

    if (query.isEmpty()) {
      return Collections.emptyIterator();
    }

    List<User> users = new LinkedList<User>();
    JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
    JaxbUser queryUser = new JaxbUser(query, PROVIDER_NAME, jaxbOrganization, new HashSet<JaxbRole>());
    users.add(queryUser);

    return users.iterator();
  }

  @Override
  public Iterator<User> getUsers() {
    // We never enumerate all users
    return Collections.emptyIterator();
  }

  @Override
  public void invalidate(String userName) {
    cache.invalidate(userName);
  }

  @Override
  public long countUsers() {
    // Not meaningful, as we never enumerate users
    return 0;
  }

  // RoleProvider methods

  @Override
  public List<Role> getRolesForUser(String userName) {

    List<Role> roles = new LinkedList<Role>();

    // Don't answer for admin, anonymous or empty user
    if ("admin".equals(userName) || "".equals(userName) || "anonymous".equals(userName)) {
      logger.debug("we don't answer for {}", userName);
      return roles;
    }

    logger.debug("getRolesForUser({})", userName);

    User user = loadUser(userName);
    if (user != null) {
      logger.debug("Returning cached role set for {}", userName);
      return new ArrayList<Role>(user.getRoles());
    }

    // Not found
    logger.debug("Return empty role set for {} - not found on Stud.IP", userName);
    return new LinkedList<>();
  }

  @Override
  public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
    logger.debug("findRoles(query={} offset={} limit={})", query, offset, limit);

    // Don't return roles for users or groups
    if (target == Role.Target.USER) {
      return Collections.emptyIterator();
    }

    if (query.endsWith("%")) {
      query = query.substring(0, query.length() - 1);
    }

    if (query.isEmpty()) {
      return Collections.emptyIterator();
    }

    // Roles list
    List<Role> roles = new LinkedList<>();

    return roles.iterator();
  }

}