/**
* 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.sakai;
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.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.BufferedInputStream;
import java.io.FileNotFoundException;
import java.io.StringReader;
import java.lang.management.ManagementFactory;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.PatternSyntaxException;
import javax.management.InstanceNotFoundException;
import javax.management.MBeanServer;
import javax.management.ObjectName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
/**
* A UserProvider that reads user roles from Sakai.
*/
public class SakaiUserProviderInstance implements UserProvider, RoleProvider, CachingUserProviderMXBean {
private static final String LTI_LEARNER_ROLE = "Learner";
private static final String LTI_INSTRUCTOR_ROLE = "Instructor";
public static final String PROVIDER_NAME = "sakai";
private static final String OC_USERAGENT = "Opencast";
/** The logger */
private static final Logger logger = LoggerFactory.getLogger(SakaiUserProviderInstance.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 Sakai */
private AtomicLong sakaiLoads = null;
/** A cache of users, which lightens the load on Sakai */
private LoadingCache<String, Object> cache = null;
/** A token to store in the miss cache */
protected Object nullToken = new Object();
/** The URL of the Sakai instance */
private String sakaiUrl = null;
/** The username used to call Sakai REST webservices */
private String sakaiUsername = null;
/** The password of the user used to call Sakai REST webservices */
private String sakaiPassword = null;
/** Regular expression for matching valid sites */
private String sitePattern;
/** Regular expression for matching valid users */
private String userPattern;
/** A map of roles which are regarded as Instructor roles */
private Set<String> instructorRoles;
/**
* Constructs an Sakai user provider with the needed settings.
*
* @param pid
* the pid of this service
* @param organization
* the organization
* @param url
* the url of the Sakai server
* @param userName
* the user to authenticate as
* @param password
* the user credentials
* @param cacheSize
* the number of users to cache
* @param cacheExpiration
* the number of minutes to cache users
*/
public SakaiUserProviderInstance(String pid, Organization organization, String url, String userName, String password,
String sitePattern, String userPattern, Set<String> instructorRoles, int cacheSize, int cacheExpiration) {
this.organization = organization;
this.sakaiUrl = url;
this.sakaiUsername = userName;
this.sakaiPassword = password;
this.sitePattern = sitePattern;
this.userPattern = userPattern;
this.instructorRoles = instructorRoles;
JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
logger.info("Creating new SakaiUserProviderInstance(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 = loadUserFromSakai(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();
sakaiLoads = new AtomicLong();
try {
ObjectName name;
name = SakaiUserProviderFactory.getObjectName(pid);
Object mbean = this;
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
try {
mbs.unregisterMBean(name);
} catch (InstanceNotFoundException e) {
logger.debug(name + " was not registered");
}
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 " + userName + " from cache");
return (JaxbUser) user;
}
} catch (ExecutionError e) {
logger.warn("Exception while loading user {}", userName, e);
return null;
} catch (UncheckedExecutionException e) {
logger.warn("Exception while loading user {}", userName, e);
return null;
}
}
/**
* Loads a user from Sakai.
*
* @param userName
* the username
* @return the user
*/
protected User loadUserFromSakai(String userName) {
if (cache == null) {
throw new IllegalStateException("The Sakai 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 loadUserFromSakai, currently processing user : {}", userName);
JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
// update cache statistics
sakaiLoads.incrementAndGet();
Thread currentThread = Thread.currentThread();
ClassLoader originalClassloader = currentThread.getContextClassLoader();
try {
// Sakai userId (internal id), email address and display name
String[] sakaiUser = getSakaiUser(userName);
if (sakaiUser == null) {
// user not known to this provider
logger.debug("User {} not found in Sakai system", userName);
cache.put(userName, nullToken);
return null;
}
String userId = sakaiUser[0];
String email = sakaiUser[1];
String displayName = sakaiUser[2];
// Get the set of Sakai roles for the user
String[] sakaiRoles = getRolesFromSakai(userId);
// if Sakai doesn't know about this user we need to return
if (sakaiRoles == null) {
cache.put(userName, nullToken);
return null;
}
logger.debug("Sakai roles for eid " + userName + " id " + userId + ": " + Arrays.toString(sakaiRoles));
Set<JaxbRole> roles = new HashSet<JaxbRole>();
for (String r : sakaiRoles) {
roles.add(new JaxbRole(r, jaxbOrganization, "Sakai external role", Role.Type.EXTERNAL));
}
// Add a group role for testing
roles.add(new JaxbRole(Group.ROLE_PREFIX + "SAKAI", jaxbOrganization, "Sakai Group", Role.Type.EXTERNAL_GROUP));
logger.debug("Returning JaxbRoles: " + roles);
// JaxbUser(String userName, String password, String name, String email, String provider, boolean canLogin, JaxbOrganization organization, Set<JaxbRole> roles)
User user = new JaxbUser(userName, null, displayName, email, PROVIDER_NAME, true, jaxbOrganization, roles);
cache.put(userName, user);
logger.debug("Returning user {}", userName);
return user;
} finally {
currentThread.setContextClassLoader(originalClassloader);
}
}
/*
** Verify that the user exists
** Query with /direct/user/:ID:/exists
*/
private boolean verifySakaiUser(String userId) {
logger.debug("verifySakaiUser({})", userId);
try {
if ((userPattern != null) && !userId.matches(userPattern)) {
logger.debug("verify user {} failed regexp {}", userId, userPattern);
return false;
}
} catch (PatternSyntaxException e) {
logger.warn("Invalid regular expression for user pattern {} - disabling checks", userPattern);
userPattern = null;
}
int code;
try {
// This webservice does not require authentication
URL url = new URL(sakaiUrl + "/direct/user/" + userId + "/exists");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", OC_USERAGENT);
connection.connect();
code = connection.getResponseCode();
} catch (Exception e) {
logger.warn("Exception verifying Sakai user " + userId + " at " + sakaiUrl + ": " + e.getMessage());
return false;
}
// HTTP OK 200 for site exists, return false for everything else (typically 404 not found)
return (code == 200);
}
/*
** Verify that the site exists
** Query with /direct/site/:ID:/exists
*/
private boolean verifySakaiSite(String siteId) {
// We could additionally cache positive and negative siteId lookup results here
logger.debug("verifySakaiSite(" + siteId + ")");
try {
if ((sitePattern != null) && !siteId.matches(sitePattern)) {
logger.debug("verify site {} failed regexp {}", siteId, sitePattern);
return false;
}
} catch (PatternSyntaxException e) {
logger.warn("Invalid regular expression for site pattern {} - disabling checks", sitePattern);
sitePattern = null;
}
int code;
try {
// This webservice does not require authentication
URL url = new URL(sakaiUrl + "/direct/site/" + siteId + "/exists");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", OC_USERAGENT);
connection.connect();
code = connection.getResponseCode();
} catch (Exception e) {
logger.warn("Exception verifying Sakai site " + siteId + " at " + sakaiUrl + ": " + e.getMessage());
return false;
}
// HTTP OK 200 for site exists, return false for everything else (typically 404 not found)
return (code == 200);
}
private String[] getRolesFromSakai(String userId) {
logger.debug("getRolesFromSakai(" + userId + ")");
try {
URL url = new URL(sakaiUrl + "/direct/membership/fastroles/" + userId + ".xml" + "?__auth=basic");
String encoded = Base64.encodeBase64String((sakaiUsername + ":" + sakaiPassword).getBytes("utf8"));
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setDoOutput(true);
connection.setRequestProperty("Authorization", "Basic " + encoded);
connection.setRequestProperty("User-Agent", OC_USERAGENT);
String xml = IOUtils.toString(new BufferedInputStream(connection.getInputStream()));
logger.debug(xml);
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder parser = documentBuilderFactory.newDocumentBuilder();
Document document = parser.parse(new org.xml.sax.InputSource(new StringReader(xml)));
Element root = document.getDocumentElement();
NodeList nodes = root.getElementsByTagName("membership");
List<String> roleList = new ArrayList<String>();
for (int i = 0; i < nodes.getLength(); i++) {
Element element = (Element) nodes.item(i);
// The Role in sakai
String sakaiRole = getTagValue("memberRole", element);
// the location in sakai e.g. /site/admin
String sakaiLocationReference = getTagValue("locationReference", element);
// we don't do the sakai admin role
if ("/site/!admin".equals(sakaiLocationReference)) {
continue;
}
String opencastRole = buildOpencastRole(sakaiLocationReference, sakaiRole);
roleList.add(opencastRole);
}
return roleList.toArray(new String[0]);
} catch (FileNotFoundException fnf) {
// if the return is 404 it means the user wasn't found
logger.debug("user id " + userId + " not found on " + sakaiUrl);
} catch (Exception e) {
logger.warn("Exception getting site/role membership for Sakai user {} at {}: {}", userId, sakaiUrl, e.getMessage());
}
return null;
}
/**
* Get the internal Sakai user Id for the supplied user. If the user exists, set the user's email address.
*
* @param eid
* @return
*/
private String[] getSakaiUser(String eid) {
try {
URL url = new URL(sakaiUrl + "/direct/user/" + eid + ".xml" + "?__auth=basic");
logger.debug("Sakai URL: " + sakaiUrl);
String encoded = Base64.encodeBase64String((sakaiUsername + ":" + sakaiPassword).getBytes("utf8"));
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setDoOutput(true);
connection.setRequestProperty("Authorization", "Basic " + encoded);
connection.setRequestProperty("User-Agent", OC_USERAGENT);
String xml = IOUtils.toString(new BufferedInputStream(connection.getInputStream()));
logger.debug(xml);
// Parse the document
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder parser = documentBuilderFactory.newDocumentBuilder();
Document document = parser.parse(new org.xml.sax.InputSource(new StringReader(xml)));
Element root = document.getDocumentElement();
String sakaiID = getTagValue("id", root);
String sakaiEmail = getTagValue("email", root);
String sakaiDisplayName = getTagValue("displayName", root);
return new String[]{sakaiID, sakaiEmail, sakaiDisplayName};
} catch (FileNotFoundException fnf) {
logger.debug("user {} does not exist on Sakai system: {}", eid, fnf);
} catch (Exception e) {
logger.warn("Exception getting Sakai user information for user {} at {}: {}", eid, sakaiUrl, e);
}
return null;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.security.api.CachingUserProviderMXBean#getCacheHitRatio()
*/
@Override
public float getCacheHitRatio() {
if (requests.get() == 0) {
return 0;
}
return (float) (requests.get() - sakaiLoads.get()) / requests.get();
}
/**
* Build a Opencast role "foo_user" from the given Sakai locations
*
* @param sakaiLocationReference
* @param sakaiRole
* @return
*/
private String buildOpencastRole(String sakaiLocationReference, String sakaiRole) {
// we need to parse the site id from the reference
String siteId = sakaiLocationReference.substring(sakaiLocationReference.indexOf("/", 2) + 1);
// map Sakai role to LTI role
String ltiRole = instructorRoles.contains(sakaiRole) ? LTI_INSTRUCTOR_ROLE : LTI_LEARNER_ROLE;
return siteId + "_" + ltiRole;
}
/**
* Get a value for for a tag in the element
*
* @param sTag
* @param eElement
* @return
*/
private static String getTagValue(String sTag, Element eElement) {
if (eElement.getElementsByTagName(sTag) == null)
return null;
NodeList nlList = eElement.getElementsByTagName(sTag).item(0).getChildNodes();
Node nValue = nlList.item(0);
return (nValue != null) ? nValue.getNodeValue() : null;
}
@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();
}
// Verify if a user exists (non-wildcard searches only)
if (!verifySakaiUser(query)) {
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 Iterator<Role> getRoles() {
// We won't ever enumerate all Sakai sites, so return an empty list here
return Collections.emptyIterator();
}
@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 roleset for {}", userName);
return new ArrayList<Role>(user.getRoles());
}
// Not found
logger.debug("Return empty roleset for {} - not found on Sakai");
return new LinkedList<Role>();
}
@Override
public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
// We search for SITEID, SITEID_Learner, SITEID_Instructor
logger.debug("findRoles(query=" + query + " offset=" + offset + " limit=" + limit + ")");
// Don't return roles for users or groups
if (target == Role.Target.USER) {
return Collections.emptyIterator();
}
boolean exact = true;
boolean ltirole = false;
if (query.endsWith("%")) {
exact = false;
query = query.substring(0, query.length() - 1);
}
if (query.isEmpty()) {
return Collections.emptyIterator();
}
// Verify that role name ends with LTI_LEARNER_ROLE or LTI_INSTRUCTOR_ROLE
if (exact && !query.endsWith("_" + LTI_LEARNER_ROLE) && !query.endsWith("_" + LTI_INSTRUCTOR_ROLE)) {
return Collections.emptyIterator();
}
String sakaiSite = null;
if (query.endsWith("_" + LTI_LEARNER_ROLE)) {
sakaiSite = query.substring(0, query.lastIndexOf("_" + LTI_LEARNER_ROLE));
ltirole = true;
} else if (query.endsWith("_" + LTI_INSTRUCTOR_ROLE)) {
sakaiSite = query.substring(0, query.lastIndexOf("_" + LTI_INSTRUCTOR_ROLE));
ltirole = true;
}
if (!ltirole) {
sakaiSite = query;
}
if (!verifySakaiSite(sakaiSite)) {
return Collections.emptyIterator();
}
// Roles list
List<Role> roles = new LinkedList<Role>();
JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
if (ltirole) {
// Query is for a Site ID and an LTI role (Instructor/Learner)
roles.add(new JaxbRole(query, jaxbOrganization, "Sakai Site Role", Role.Type.EXTERNAL));
} else {
// Site ID - return both roles
roles.add(new JaxbRole(sakaiSite + "_" + LTI_INSTRUCTOR_ROLE, jaxbOrganization, "Sakai Site Instructor Role", Role.Type.EXTERNAL));
roles.add(new JaxbRole(sakaiSite + "_" + LTI_LEARNER_ROLE, jaxbOrganization, "Sakai Site Learner Role", Role.Type.EXTERNAL));
}
return roles.iterator();
}
}