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.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
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
83 private static final Logger logger = LoggerFactory.getLogger(SakaiUserProviderInstance.class);
84
85
86 private Organization organization = null;
87
88
89 private AtomicLong requests = null;
90
91
92 private AtomicLong sakaiLoads = null;
93
94
95 private LoadingCache<String, Object> cache = null;
96
97
98 protected Object nullToken = new Object();
99
100
101 private String sakaiUrl = null;
102
103
104 private String sakaiUsername = null;
105
106
107 private String sakaiPassword = null;
108
109
110 private String sitePattern;
111
112
113 private String userPattern;
114
115
116 private Set<String> instructorRoles;
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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
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
177
178
179
180
181
182
183 @Override
184 public String getOrganization() {
185 return organization.getId();
186 }
187
188
189
190
191
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
228
229
230
231
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
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
251 sakaiLoads.incrementAndGet();
252
253 Thread currentThread = Thread.currentThread();
254 ClassLoader originalClassloader = currentThread.getContextClassLoader();
255 try {
256
257
258 String[] sakaiUser = getSakaiUser(userName);
259
260 if (sakaiUser == null) {
261
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
272 String[] sakaiRoles = getRolesFromSakai(userId);
273
274
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
295 roles.add(new JaxbRole(Group.ROLE_PREFIX + "SAKAI", jaxbOrganization, "Sakai Users", Role.Type.EXTERNAL_GROUP));
296
297
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
324
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
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
358 return (code == 200);
359 }
360
361
362
363
364
365 private boolean verifySakaiSite(String siteId) {
366
367
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
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
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
428 String sakaiRole = getTagValue("memberRole", element);
429
430
431 String sakaiLocationReference = getTagValue("locationReference", element);
432
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
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
460
461
462
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
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
502
503
504
505
506
507 private String buildOpencastRole(String sakaiLocationReference, String sakaiRole) {
508
509
510 String siteId = sakaiLocationReference.substring(sakaiLocationReference.indexOf("/", 2) + 1);
511
512
513 String ltiRole = instructorRoles.contains(sakaiRole) ? LTI_INSTRUCTOR_ROLE : LTI_LEARNER_ROLE;
514
515 return siteId + "_" + ltiRole;
516 }
517
518
519
520
521
522
523
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
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
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
577 return 0;
578 }
579
580
581
582 @Override
583 public List<Role> getRolesForUser(String userName) {
584
585 List<Role> roles = new LinkedList<Role>();
586
587
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
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
610
611 logger.debug("findRoles(query=" + query + " offset=" + offset + " limit=" + limit + ")");
612
613
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
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
654 List<Role> roles = new LinkedList<Role>();
655
656 JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
657
658 if (ltirole) {
659
660 roles.add(new JaxbRole(query, jaxbOrganization, "Sakai Site Role", Role.Type.EXTERNAL));
661 } else {
662
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 }