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