1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
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
88 private static final Logger logger = LoggerFactory.getLogger(SakaiUserProviderInstance.class);
89
90
91 private Organization organization = null;
92
93
94 private AtomicLong requests = null;
95
96
97 private AtomicLong sakaiLoads = null;
98
99
100 private LoadingCache<String, Object> cache = null;
101
102
103 protected Object nullToken = new Object();
104
105
106 private String sakaiUrl = null;
107
108
109 private String sakaiUsername = null;
110
111
112 private String sakaiPassword = null;
113
114
115 private String sitePattern;
116
117
118 private String userPattern;
119
120
121 private Set<String> instructorRoles;
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
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
185
186 protected void registerMBean(String pid) {
187
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
207
208
209
210
211
212
213 @Override
214 public String getOrganization() {
215 return organization.getId();
216 }
217
218
219
220
221
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
258
259
260
261
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
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
281 sakaiLoads.incrementAndGet();
282
283 Thread currentThread = Thread.currentThread();
284 ClassLoader originalClassloader = currentThread.getContextClassLoader();
285 try {
286
287
288 String[] sakaiUser = getSakaiUser(userName);
289
290 if (sakaiUser == null) {
291
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
302 String[] sakaiRoles = getRolesFromSakai(userId);
303
304
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
325 roles.add(new JaxbRole(Group.ROLE_PREFIX + "SAKAI", jaxbOrganization, "Sakai Users", Role.Type.EXTERNAL_GROUP));
326
327
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
354
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
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
388 return (code == 200);
389 }
390
391
392
393
394
395 private boolean verifySakaiSite(String siteId) {
396
397
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
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
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
458 String sakaiRole = getTagValue("memberRole", element);
459
460
461 String sakaiLocationReference = getTagValue("locationReference", element);
462
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
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
490
491
492
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
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
532
533
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
545
546
547
548
549
550 private String buildOpencastRole(String sakaiLocationReference, String sakaiRole) {
551
552
553 String siteId = sakaiLocationReference.substring(sakaiLocationReference.indexOf("/", 2) + 1);
554
555
556 String ltiRole = instructorRoles.contains(sakaiRole) ? LTI_INSTRUCTOR_ROLE : LTI_LEARNER_ROLE;
557
558 return siteId + "_" + ltiRole;
559 }
560
561
562
563
564
565
566
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
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
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
620 return 0;
621 }
622
623
624
625 @Override
626 public List<Role> getRolesForUser(String userName) {
627
628 List<Role> roles = new LinkedList<Role>();
629
630
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
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
653
654 logger.debug("findRoles(query=" + query + " offset=" + offset + " limit=" + limit + ")");
655
656
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
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
697 List<Role> roles = new LinkedList<Role>();
698
699 JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
700
701 if (ltirole) {
702
703 roles.add(new JaxbRole(query, jaxbOrganization, "Sakai Site Role", Role.Type.EXTERNAL));
704 } else {
705
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 }