View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.userdirectory.sakai;
23  
24  import org.opencastproject.security.api.CachingUserProviderMXBean;
25  import org.opencastproject.security.api.Group;
26  import org.opencastproject.security.api.JaxbOrganization;
27  import org.opencastproject.security.api.JaxbRole;
28  import org.opencastproject.security.api.JaxbUser;
29  import org.opencastproject.security.api.Organization;
30  import org.opencastproject.security.api.Role;
31  import org.opencastproject.security.api.RoleProvider;
32  import org.opencastproject.security.api.User;
33  import org.opencastproject.security.api.UserProvider;
34  import org.opencastproject.util.XmlSafeParser;
35  
36  import com.google.common.cache.CacheBuilder;
37  import com.google.common.cache.CacheLoader;
38  import com.google.common.cache.LoadingCache;
39  import com.google.common.util.concurrent.ExecutionError;
40  import com.google.common.util.concurrent.UncheckedExecutionException;
41  
42  import org.apache.commons.codec.binary.Base64;
43  import org.apache.commons.io.IOUtils;
44  import org.slf4j.Logger;
45  import org.slf4j.LoggerFactory;
46  import org.w3c.dom.Document;
47  import org.w3c.dom.Element;
48  import org.w3c.dom.Node;
49  import org.w3c.dom.NodeList;
50  
51  import java.io.BufferedInputStream;
52  import java.io.FileNotFoundException;
53  import java.io.StringReader;
54  import java.lang.management.ManagementFactory;
55  import java.net.HttpURLConnection;
56  import java.net.URL;
57  import java.util.ArrayList;
58  import java.util.Arrays;
59  import java.util.Collections;
60  import java.util.HashSet;
61  import java.util.Iterator;
62  import java.util.LinkedList;
63  import java.util.List;
64  import java.util.Set;
65  import java.util.concurrent.TimeUnit;
66  import java.util.concurrent.atomic.AtomicLong;
67  import java.util.regex.PatternSyntaxException;
68  
69  import javax.management.InstanceNotFoundException;
70  import javax.management.MBeanServer;
71  import javax.management.ObjectName;
72  import javax.xml.parsers.DocumentBuilder;
73  
74  /**
75   * A UserProvider that reads user roles from Sakai.
76   */
77  public class SakaiUserProviderInstance implements UserProvider, RoleProvider, CachingUserProviderMXBean {
78  
79    private static final String LTI_LEARNER_ROLE = "Learner";
80  
81    private static final String LTI_INSTRUCTOR_ROLE = "Instructor";
82  
83    public static final String PROVIDER_NAME = "sakai";
84  
85    private static final String OC_USERAGENT = "Opencast";
86  
87    /** The logger */
88    private static final Logger logger = LoggerFactory.getLogger(SakaiUserProviderInstance.class);
89  
90    /** The organization */
91    private Organization organization = null;
92  
93    /** Total number of requests made to load users */
94    private AtomicLong requests = null;
95  
96    /** The number of requests made to Sakai */
97    private AtomicLong sakaiLoads = null;
98  
99    /** A cache of users, which lightens the load on Sakai */
100   private LoadingCache<String, Object> cache = null;
101 
102   /** A token to store in the miss cache */
103   protected Object nullToken = new Object();
104 
105   /** The URL of the Sakai instance */
106   private String sakaiUrl = null;
107 
108   /** The username used to call Sakai REST webservices */
109   private String sakaiUsername = null;
110 
111   /** The password of the user used to call Sakai REST webservices */
112   private String sakaiPassword = null;
113 
114   /** Regular expression for matching valid sites */
115   private String sitePattern;
116 
117   /** Regular expression for matching valid users */
118   private String userPattern;
119 
120   /** A map of roles which are regarded as Instructor roles */
121   private Set<String> instructorRoles;
122 
123   /**
124    * Constructs an Sakai user provider with the needed settings.
125    *
126    * @param pid
127    *          the pid of this service
128    * @param organization
129    *          the organization
130    * @param url
131    *          the url of the Sakai server
132    * @param userName
133    *          the user to authenticate as
134    * @param password
135    *          the user credentials
136    * @param cacheSize
137    *          the number of users to cache
138    * @param cacheExpiration
139    *          the number of minutes to cache users
140    */
141   public SakaiUserProviderInstance(
142       String pid,
143       Organization organization,
144       String url,
145       String userName,
146       String password,
147       String sitePattern,
148       String userPattern,
149       Set<String> instructorRoles,
150       int cacheSize,
151       int cacheExpiration
152   ) {
153 
154     this.organization = organization;
155     this.sakaiUrl = url;
156     this.sakaiUsername = userName;
157     this.sakaiPassword = password;
158     this.sitePattern = sitePattern;
159     this.userPattern = userPattern;
160     this.instructorRoles = instructorRoles;
161 
162     logger.info("Creating new SakaiUserProviderInstance(pid={}, url={}, cacheSize={}, cacheExpiration={})",
163                  pid, url, cacheSize, cacheExpiration);
164 
165     // Setup the caches
166     cache = CacheBuilder.newBuilder().maximumSize(cacheSize).expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
167         .build(new CacheLoader<String, Object>() {
168           @Override
169           public Object load(String id) throws Exception {
170             User user = loadUserFromSakai(id);
171             return user == null ? nullToken : user;
172           }
173         });
174 
175     registerMBean(pid);
176   }
177 
178   @Override
179   public String getName() {
180     return PROVIDER_NAME;
181   }
182 
183   /**
184    * Registers an MXBean.
185    */
186   protected void registerMBean(String pid) {
187     // register with jmx
188     requests = new AtomicLong();
189     sakaiLoads = new AtomicLong();
190     try {
191       ObjectName name;
192       name = SakaiUserProviderFactory.getObjectName(pid);
193       Object mbean = this;
194       MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
195       try {
196         mbs.unregisterMBean(name);
197       } catch (InstanceNotFoundException e) {
198         logger.debug(name + " was not registered");
199       }
200       mbs.registerMBean(mbean, name);
201     } catch (Exception e) {
202       logger.error("Unable to register {} as an mbean", this, e);
203     }
204   }
205 
206   // UserProvider methods
207 
208   /**
209    * {@inheritDoc}
210    * 
211    * @see org.opencastproject.security.api.UserProvider#getOrganization()
212    */
213   @Override
214   public String getOrganization() {
215     return organization.getId();
216   }
217 
218   /**
219    * {@inheritDoc}
220    * 
221    * @see org.opencastproject.security.api.UserProvider#loadUser(java.lang.String)
222    */
223   @Override
224   public User loadUser(String userName) {
225     logger.debug("loaduser(" + userName + ")");
226 
227     try {
228       if ((userPattern != null) && !userName.matches(userPattern)) {
229         logger.debug("load user {} failed regexp {}", userName, userPattern);
230         return null;
231       }
232     } catch (PatternSyntaxException e) {
233       logger.warn("Invalid regular expression for user pattern {} - disabling checks", userPattern);
234       userPattern = null;
235     }
236 
237     requests.incrementAndGet();
238     try {
239       Object user = cache.getUnchecked(userName);
240       if (user == nullToken) {
241         logger.debug("Returning null user from cache");
242         return null;
243       } else {
244         logger.debug("Returning user " + userName + " from cache");
245         return (JaxbUser) user;
246       }
247     } catch (ExecutionError e) {
248       logger.warn("Exception while loading user {}", userName, e);
249       return null;
250     } catch (UncheckedExecutionException e) {
251       logger.warn("Exception while loading user {}", userName, e);
252       return null;
253     }
254   }
255 
256   /**
257    * Loads a user from Sakai.
258    * 
259    * @param userName
260    *          the username
261    * @return the user
262    */
263   protected User loadUserFromSakai(String userName) {
264 
265     if (cache == null) {
266       throw new IllegalStateException("The Sakai user detail service has not yet been configured");
267     }
268 
269     // Don't answer for admin, anonymous or empty user
270     if ("admin".equals(userName) || "".equals(userName) || "anonymous".equals(userName)) {
271       cache.put(userName, nullToken);
272       logger.debug("we don't answer for: " + userName);
273       return null;
274     }
275 
276     logger.debug("In loadUserFromSakai, currently processing user : {}", userName);
277 
278     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
279 
280     // update cache statistics
281     sakaiLoads.incrementAndGet();
282 
283     Thread currentThread = Thread.currentThread();
284     ClassLoader originalClassloader = currentThread.getContextClassLoader();
285     try {
286 
287       // Sakai userId (internal id), email address and display name
288       String[] sakaiUser = getSakaiUser(userName);
289 
290       if (sakaiUser == null) {
291         // user not known to this provider
292         logger.debug("User {} not found in Sakai system", userName);
293         cache.put(userName, nullToken);
294         return null;
295       }
296 
297       String userId = sakaiUser[0];
298       String email = sakaiUser[1];
299       String displayName = sakaiUser[2];
300 
301       // Get the set of Sakai roles for the user
302       String[] sakaiRoles = getRolesFromSakai(userId);
303 
304       // if Sakai doesn't know about this user we need to return
305       if (sakaiRoles == null) {
306         cache.put(userName, nullToken);
307         return null;
308       }
309 
310       logger.debug("Sakai roles for eid " + userName + " id " + userId + ": " + Arrays.toString(sakaiRoles));
311 
312       Set<JaxbRole> roles = new HashSet<JaxbRole>();
313 
314       boolean isInstructor = false;
315 
316       for (String r : sakaiRoles) {
317         roles.add(new JaxbRole(r, jaxbOrganization, "Sakai external role", Role.Type.EXTERNAL));
318 
319         if (r.endsWith(LTI_INSTRUCTOR_ROLE)) {
320           isInstructor = true;
321         }
322       }
323 
324       // Group role for all Sakai users
325       roles.add(new JaxbRole(Group.ROLE_PREFIX + "SAKAI", jaxbOrganization, "Sakai Users", Role.Type.EXTERNAL_GROUP));
326 
327       // Group role for Sakai users who are an instructor in one more sites
328       if (isInstructor) {
329         roles.add(new JaxbRole(
330             Group.ROLE_PREFIX + "SAKAI_INSTRUCTOR",
331             jaxbOrganization,
332             "Sakai Instructors",
333             Role.Type.EXTERNAL_GROUP
334         ));
335       }
336 
337       logger.debug("Returning JaxbRoles: " + roles);
338 
339       User user = new JaxbUser(userName, null, displayName, email, PROVIDER_NAME, jaxbOrganization, roles);
340 
341       cache.put(userName, user);
342       logger.debug("Returning user {}", userName);
343 
344       return user;
345 
346     } finally {
347       currentThread.setContextClassLoader(originalClassloader);
348     }
349 
350   }
351 
352   /*
353    ** Verify that the user exists
354    ** Query with /direct/user/:ID:/exists
355    */
356   private boolean verifySakaiUser(String userId) {
357 
358     logger.debug("verifySakaiUser({})", userId);
359 
360     try {
361       if ((userPattern != null) && !userId.matches(userPattern)) {
362         logger.debug("verify user {} failed regexp {}", userId, userPattern);
363         return false;
364       }
365     } catch (PatternSyntaxException e) {
366       logger.warn("Invalid regular expression for user pattern {} - disabling checks", userPattern);
367       userPattern = null;
368     }
369 
370     int code;
371 
372     try {
373       // This webservice does not require authentication
374       URL url = new URL(sakaiUrl + "/direct/user/" + userId + "/exists");
375 
376       HttpURLConnection connection = (HttpURLConnection) url.openConnection();
377       connection.setRequestMethod("GET");
378       connection.setRequestProperty("User-Agent", OC_USERAGENT);
379 
380       connection.connect();
381       code = connection.getResponseCode();
382     } catch (Exception e) {
383       logger.warn("Exception verifying Sakai user " + userId + " at " + sakaiUrl + ": " + e.getMessage());
384       return false;
385     }
386 
387     // HTTP OK 200 for site exists, return false for everything else (typically 404 not found)
388     return (code == 200);
389   }
390 
391   /*
392    ** Verify that the site exists
393    ** Query with /direct/site/:ID:/exists
394    */
395   private boolean verifySakaiSite(String siteId) {
396 
397     // We could additionally cache positive and negative siteId lookup results here
398 
399     logger.debug("verifySakaiSite(" + siteId + ")");
400 
401     try {
402       if ((sitePattern != null) && !siteId.matches(sitePattern)) {
403         logger.debug("verify site {} failed regexp {}", siteId, sitePattern);
404         return false;
405       }
406     } catch (PatternSyntaxException e) {
407       logger.warn("Invalid regular expression for site pattern {} - disabling checks", sitePattern);
408       sitePattern = null;
409     }
410 
411     int code;
412 
413     try {
414       // This webservice does not require authentication
415       URL url = new URL(sakaiUrl + "/direct/site/" + siteId + "/exists");
416 
417       HttpURLConnection connection = (HttpURLConnection) url.openConnection();
418       connection.setRequestMethod("GET");
419       connection.setRequestProperty("User-Agent", OC_USERAGENT);
420 
421       connection.connect();
422       code = connection.getResponseCode();
423     } catch (Exception e) {
424       logger.warn("Exception verifying Sakai site " + siteId + " at " + sakaiUrl + ": " + e.getMessage());
425       return false;
426     }
427 
428     // HTTP OK 200 for site exists, return false for everything else (typically 404 not found)
429     return (code == 200);
430   }
431 
432   private String[] getRolesFromSakai(String userId) {
433     logger.debug("getRolesFromSakai(" + userId + ")");
434     try {
435 
436       URL url = new URL(sakaiUrl + "/direct/membership/fastroles/" + userId + ".xml" + "?__auth=basic");
437       String encoded = Base64.encodeBase64String((sakaiUsername + ":" + sakaiPassword).getBytes("utf8"));
438 
439       HttpURLConnection connection = (HttpURLConnection) url.openConnection();
440       connection.setRequestMethod("GET");
441       connection.setDoOutput(true);
442       connection.setRequestProperty("Authorization", "Basic " + encoded);
443       connection.setRequestProperty("User-Agent", OC_USERAGENT);
444 
445       String xml = IOUtils.toString(new BufferedInputStream(connection.getInputStream()));
446       logger.debug(xml);
447 
448       DocumentBuilder parser = XmlSafeParser.newDocumentBuilderFactory().newDocumentBuilder();
449 
450       Document document = parser.parse(new org.xml.sax.InputSource(new StringReader(xml)));
451 
452       Element root = document.getDocumentElement();
453       NodeList nodes = root.getElementsByTagName("membership");
454       List<String> roleList = new ArrayList<String>();
455       for (int i = 0; i < nodes.getLength(); i++) {
456         Element element = (Element) nodes.item(i);
457         // The Role in sakai
458         String sakaiRole = getTagValue("memberRole", element);
459 
460         // the location in sakai e.g. /site/admin
461         String sakaiLocationReference = getTagValue("locationReference", element);
462         // we don't do the sakai admin role
463         if ("/site/!admin".equals(sakaiLocationReference)) {
464           continue;
465         }
466 
467         String opencastRole = buildOpencastRole(sakaiLocationReference, sakaiRole);
468         roleList.add(opencastRole);
469       }
470 
471       return roleList.toArray(new String[0]);
472 
473     } catch (FileNotFoundException fnf) {
474       // if the return is 404 it means the user wasn't found
475       logger.debug("user id " + userId + " not found on " + sakaiUrl);
476     } catch (Exception e) {
477       logger.warn(
478           "Exception getting site/role membership for Sakai user {} at {}: {}",
479           userId,
480           sakaiUrl,
481           e.getMessage()
482       );
483     }
484 
485     return null;
486   }
487 
488   /**
489    * Get the internal Sakai user Id for the supplied user. If the user exists, set the user's email address.
490    * 
491    * @param eid
492    * @return
493    */
494   private String[] getSakaiUser(String eid) {
495 
496     try {
497 
498       URL url = new URL(sakaiUrl + "/direct/user/" + eid + ".xml" + "?__auth=basic");
499       logger.debug("Sakai URL: " + sakaiUrl);
500       String encoded = Base64.encodeBase64String((sakaiUsername + ":" + sakaiPassword).getBytes("utf8"));
501       HttpURLConnection connection = (HttpURLConnection) url.openConnection();
502       connection.setRequestMethod("GET");
503       connection.setDoOutput(true);
504       connection.setRequestProperty("Authorization", "Basic " + encoded);
505       connection.setRequestProperty("User-Agent", OC_USERAGENT);
506 
507       String xml = IOUtils.toString(new BufferedInputStream(connection.getInputStream()));
508       logger.debug(xml);
509 
510       // Parse the document
511       DocumentBuilder parser = XmlSafeParser.newDocumentBuilderFactory().newDocumentBuilder();
512       Document document = parser.parse(new org.xml.sax.InputSource(new StringReader(xml)));
513       Element root = document.getDocumentElement();
514 
515       String sakaiID = getTagValue("id", root);
516       String sakaiEmail = getTagValue("email", root);
517       String sakaiDisplayName = getTagValue("displayName", root);
518 
519       return new String[]{sakaiID, sakaiEmail, sakaiDisplayName};
520 
521     } catch (FileNotFoundException fnf) {
522       logger.debug("user {} does not exist on Sakai system", eid, fnf);
523     } catch (Exception e) {
524       logger.warn("Exception getting Sakai user information for user {} at {}", eid, sakaiUrl, e);
525     }
526 
527     return null;
528   }
529 
530   /**
531    * {@inheritDoc}
532    * 
533    * @see org.opencastproject.security.api.CachingUserProviderMXBean#getCacheHitRatio()
534    */
535   @Override
536   public float getCacheHitRatio() {
537     if (requests.get() == 0) {
538       return 0;
539     }
540     return (float) (requests.get() - sakaiLoads.get()) / requests.get();
541   }
542 
543   /**
544    * Build a Opencast role "foo_user" from the given Sakai locations
545    * 
546    * @param sakaiLocationReference
547    * @param sakaiRole
548    * @return
549    */
550   private String buildOpencastRole(String sakaiLocationReference, String sakaiRole) {
551 
552     // we need to parse the site id from the reference
553     String siteId = sakaiLocationReference.substring(sakaiLocationReference.indexOf("/", 2) + 1);
554 
555     // map Sakai role to LTI role
556     String ltiRole = instructorRoles.contains(sakaiRole) ? LTI_INSTRUCTOR_ROLE : LTI_LEARNER_ROLE;
557 
558     return siteId + "_" + ltiRole;
559   }
560 
561   /**
562    * Get a value for for a tag in the element
563    * 
564    * @param sTag
565    * @param eElement
566    * @return
567    */
568   private static String getTagValue(String sTag, Element eElement) {
569     if (eElement.getElementsByTagName(sTag) == null) {
570       return null;
571     }
572 
573     NodeList nlList = eElement.getElementsByTagName(sTag).item(0).getChildNodes();
574     Node nValue = nlList.item(0);
575     return (nValue != null) ? nValue.getNodeValue() : null;
576   }
577 
578   @Override
579   public Iterator<User> findUsers(String query, int offset, int limit) {
580 
581     if (query == null) {
582       throw new IllegalArgumentException("Query must be set");
583     }
584 
585     if (query.endsWith("%")) {
586       query = query.substring(0, query.length() - 1);
587     }
588 
589     if (query.isEmpty()) {
590       return Collections.emptyIterator();
591     }
592 
593     // Verify if a user exists (non-wildcard searches only)
594     if (!verifySakaiUser(query)) {
595       return Collections.emptyIterator();
596     }
597 
598     List<User> users = new LinkedList<User>();
599     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
600     JaxbUser queryUser = new JaxbUser(query, PROVIDER_NAME, jaxbOrganization, new HashSet<JaxbRole>());
601     users.add(queryUser);
602 
603     return users.iterator();
604   }
605 
606   @Override
607   public Iterator<User> getUsers() {
608     // We never enumerate all users
609     return Collections.emptyIterator();
610   }
611 
612   @Override
613   public void invalidate(String userName) {
614     cache.invalidate(userName);
615   }
616 
617   @Override
618   public long countUsers() {
619     // Not meaningful, as we never enumerate users
620     return 0;
621   }
622 
623   // RoleProvider methods
624 
625   @Override
626   public List<Role> getRolesForUser(String userName) {
627 
628     List<Role> roles = new LinkedList<Role>();
629 
630     // Don't answer for admin, anonymous or empty user
631     if ("admin".equals(userName) || "".equals(userName) || "anonymous".equals(userName)) {
632       logger.debug("we don't answer for: " + userName);
633       return roles;
634     }
635 
636     logger.debug("getRolesForUser(" + userName + ")");
637 
638     User user = loadUser(userName);
639     if (user != null) {
640       logger.debug("Returning cached roleset for {}", userName);
641       return new ArrayList<Role>(user.getRoles());
642     }
643 
644     // Not found
645     logger.debug("Return empty roleset for {} - not found on Sakai", userName);
646     return new LinkedList<Role>();
647   }
648 
649   @Override
650   public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
651 
652     // We search for SITEID, SITEID_Learner, SITEID_Instructor
653 
654     logger.debug("findRoles(query=" + query + " offset=" + offset + " limit=" + limit + ")");
655 
656     // Don't return roles for users or groups
657     if (target == Role.Target.USER) {
658       return Collections.emptyIterator();
659     }
660 
661     boolean exact = true;
662     boolean ltirole = false;
663 
664     if (query.endsWith("%")) {
665       exact = false;
666       query = query.substring(0, query.length() - 1);
667     }
668 
669     if (query.isEmpty()) {
670       return Collections.emptyIterator();
671     }
672 
673     // Verify that role name ends with LTI_LEARNER_ROLE or LTI_INSTRUCTOR_ROLE
674     if (exact && !query.endsWith("_" + LTI_LEARNER_ROLE) && !query.endsWith("_" + LTI_INSTRUCTOR_ROLE)) {
675       return Collections.emptyIterator();
676     }
677 
678     String sakaiSite = null;
679 
680     if (query.endsWith("_" + LTI_LEARNER_ROLE)) {
681       sakaiSite = query.substring(0, query.lastIndexOf("_" + LTI_LEARNER_ROLE));
682       ltirole = true;
683     } else if (query.endsWith("_" + LTI_INSTRUCTOR_ROLE)) {
684       sakaiSite = query.substring(0, query.lastIndexOf("_" + LTI_INSTRUCTOR_ROLE));
685       ltirole = true;
686     }
687 
688     if (!ltirole) {
689       sakaiSite = query;
690     }
691 
692     if (!verifySakaiSite(sakaiSite)) {
693       return Collections.emptyIterator();
694     }
695 
696     // Roles list
697     List<Role> roles = new LinkedList<Role>();
698 
699     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
700 
701     if (ltirole) {
702       // Query is for a Site ID and an LTI role (Instructor/Learner)
703       roles.add(new JaxbRole(query, jaxbOrganization, "Sakai Site Role", Role.Type.EXTERNAL));
704     } else {
705       // Site ID - return both roles
706       roles.add(new JaxbRole(
707           sakaiSite + "_" + LTI_INSTRUCTOR_ROLE,
708           jaxbOrganization,
709           "Sakai Site Instructor Role",
710           Role.Type.EXTERNAL
711       ));
712       roles.add(new JaxbRole(
713           sakaiSite + "_" + LTI_LEARNER_ROLE,
714           jaxbOrganization,
715           "Sakai Site Learner Role",
716           Role.Type.EXTERNAL
717       ));
718     }
719 
720     return roles.iterator();
721   }
722 
723 }