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.studip;
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  
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.http.client.config.RequestConfig;
42  import org.apache.http.client.methods.CloseableHttpResponse;
43  import org.apache.http.client.methods.HttpGet;
44  import org.apache.http.client.utils.URIBuilder;
45  import org.apache.http.impl.client.CloseableHttpClient;
46  import org.apache.http.impl.client.HttpClientBuilder;
47  import org.json.simple.JSONArray;
48  import org.json.simple.JSONObject;
49  import org.json.simple.parser.JSONParser;
50  import org.json.simple.parser.ParseException;
51  import org.slf4j.Logger;
52  import org.slf4j.LoggerFactory;
53  
54  import java.io.BufferedReader;
55  import java.io.IOException;
56  import java.io.InputStreamReader;
57  import java.lang.management.ManagementFactory;
58  import java.net.URI;
59  import java.net.URISyntaxException;
60  import java.util.ArrayList;
61  import java.util.Collections;
62  import java.util.HashSet;
63  import java.util.Iterator;
64  import java.util.LinkedList;
65  import java.util.List;
66  import java.util.Objects;
67  import java.util.Set;
68  import java.util.concurrent.TimeUnit;
69  import java.util.concurrent.atomic.AtomicLong;
70  
71  import javax.management.InstanceNotFoundException;
72  import javax.management.MBeanServer;
73  import javax.management.ObjectName;
74  
75  /**
76   * A UserProvider that reads user roles from Studip.
77   */
78  public class StudipUserProviderInstance implements UserProvider, RoleProvider, CachingUserProviderMXBean {
79  
80    public static final String PROVIDER_NAME = "studip";
81  
82    private static final String OC_USERAGENT = "Opencast";
83    private static final String STUDIP_GROUP = Group.ROLE_PREFIX + "STUDIP";
84  
85    /** The logger */
86    private static final Logger logger = LoggerFactory.getLogger(StudipUserProviderInstance.class);
87  
88    /** The organization */
89    private Organization organization = null;
90  
91    /** Total number of requests made to load users */
92    private AtomicLong requests = null;
93  
94    /** The number of requests made to Studip */
95    private AtomicLong studipLoads = null;
96  
97    /** A cache of users, which lightens the load on Studip */
98    private LoadingCache<String, Object> cache = null;
99  
100   /** A token to store in the miss cache */
101   protected Object nullToken = new Object();
102 
103   /** The URL of the Studip instance */
104   private URI studipUrl;
105 
106   /** The URL of the Studip instance */
107   private String studipToken = null;
108 
109   /**
110    * Constructs an Studip user provider with the needed settings.
111    *
112    * @param pid
113    *          the pid of this service
114    * @param organization
115    *          the organization
116    * @param url
117    *          the url of the Studip server
118    * @param token
119    *          the token to authenticate with
120    * @param cacheSize
121    *          the number of users to cache
122    * @param cacheExpiration
123    *          the number of minutes to cache users
124    */
125   public StudipUserProviderInstance(
126       String pid,
127       Organization organization,
128       URI url,
129       String token,
130 
131       int cacheSize,
132       int cacheExpiration
133   ) {
134 
135     this.organization = organization;
136     this.studipUrl = url;
137     this.studipToken = token;
138 
139     logger.info("Creating new StudipUserProviderInstance(pid={}, url={}, cacheSize={}, cacheExpiration={})",
140                  pid, url, cacheSize, cacheExpiration);
141 
142     // Setup the caches
143     cache = CacheBuilder.newBuilder().maximumSize(cacheSize).expireAfterWrite(cacheExpiration, TimeUnit.MINUTES)
144         .build(new CacheLoader<String, Object>() {
145           @Override
146           public Object load(String id) throws Exception {
147             User user = loadUserFromStudip(id);
148             return user == null ? nullToken : user;
149           }
150         });
151 
152     registerMBean(pid);
153   }
154 
155   @Override
156   public String getName() {
157     return PROVIDER_NAME;
158   }
159 
160   /**
161    * Registers an MXBean.
162    */
163   protected void registerMBean(String pid) {
164     // register with jmx
165     requests = new AtomicLong();
166     studipLoads = new AtomicLong();
167     try {
168       ObjectName name;
169       name = StudipUserProviderFactory.getObjectName(pid);
170       Object mbean = this;
171       MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
172       try {
173         mbs.unregisterMBean(name);
174       } catch (InstanceNotFoundException e) {
175         logger.debug("{} was not registered before", name);
176       }
177       mbs.registerMBean(mbean, name);
178     } catch (Exception e) {
179       logger.error("Unable to register {} as an mbean", this, e);
180     }
181   }
182 
183   // UserProvider methods
184 
185   /**
186    * {@inheritDoc}
187    * 
188    * @see org.opencastproject.security.api.UserProvider#getOrganization()
189    */
190   @Override
191   public String getOrganization() {
192     return organization.getId();
193   }
194 
195   /**
196    * {@inheritDoc}
197    * 
198    * @see org.opencastproject.security.api.UserProvider#loadUser(java.lang.String)
199    */
200   @Override
201   public User loadUser(String userName) {
202     logger.debug("loaduser({})", userName);
203 
204     requests.incrementAndGet();
205     try {
206       Object user = cache.getUnchecked(userName);
207       if (user == nullToken) {
208         logger.debug("Returning null user from cache");
209         return null;
210       } else {
211         logger.debug("Returning user {} from cache", userName);
212         return (JaxbUser) user;
213       }
214     } catch (ExecutionError | UncheckedExecutionException e) {
215       logger.warn("Exception while loading user {}", userName, e);
216       return null;
217     }
218   }
219 
220   /**
221    * Loads a user from Stud.IP.
222    * 
223    * @param userName
224    *          the username
225    * @return the user
226    */
227   protected User loadUserFromStudip(String userName) {
228     if (cache == null) {
229       throw new IllegalStateException("The Stud.IP user detail service has not yet been configured");
230     }
231 
232     // Don't answer for admin, anonymous or empty user
233     if ("admin".equals(userName) || "".equals(userName) || "anonymous".equals(userName)) {
234       cache.put(userName, nullToken);
235       logger.debug("we don't answer for {}", userName);
236       return null;
237     }
238 
239     logger.debug("In loadUserFromStudip, currently processing user : {}", userName);
240 
241     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
242 
243     // update cache statistics
244     studipLoads.incrementAndGet();
245 
246     Thread currentThread = Thread.currentThread();
247     ClassLoader originalClassloader = currentThread.getContextClassLoader();
248     try {
249       // Stud.IP userId (internal id), email address and display name
250       JSONObject userJsonObj = getStudipUser(userName);
251       if (userJsonObj == null) {
252         return null;
253       }
254 
255       Set<JaxbRole> roles = new HashSet<>();
256       if (userJsonObj.containsKey("roles")) {
257         JSONArray rolesArray = (JSONArray) userJsonObj.get("roles");
258         for (Object r : rolesArray) {
259           roles.add(new JaxbRole(r.toString(), jaxbOrganization, "Studip external role", Role.Type.EXTERNAL));
260         }
261       }
262 
263       // Group role for all Stud.IP users
264       roles.add(new JaxbRole(STUDIP_GROUP, jaxbOrganization, "Studip Users", Role.Type.EXTERNAL_GROUP));
265       logger.debug("Returning JaxbRoles: {}", roles);
266 
267       // Email address
268       var email = Objects.toString(userJsonObj.get("email"), null);
269       var name = Objects.toString(userJsonObj.get("fullname"), null);
270 
271       User user = new JaxbUser(userName, null, name, email, PROVIDER_NAME, jaxbOrganization, roles);
272 
273       cache.put(userName, user);
274       logger.debug("Returning user {}", userName);
275 
276       return user;
277 
278     } catch (ParseException e) {
279       logger.error("Exception while parsing response from provider for user {}", userName, e);
280       return null;
281     } catch (IOException e) {
282       logger.error("Error requesting user data for user `{}`: {}", userName, e.getMessage());
283       return null;
284     } catch (URISyntaxException e) {
285       logger.error("Misspelled URI", e);
286       return null;
287     } finally {
288       currentThread.setContextClassLoader(originalClassloader);
289     }
290   }
291 
292   /**
293    * Get the internal Stud.IP user Id for the supplied user. If the user exists, set the user's email address.
294    * 
295    * @param uid Identifier of the user to look for
296    * @return JSON object containing user information
297    */
298   private JSONObject getStudipUser(String uid) throws URISyntaxException, IOException, ParseException {
299     // Build URL
300     var apiPath = new URIBuilder().setPathSegments("opencast", "user", uid).getPath();
301     var url = new URIBuilder(studipUrl)
302         .setPath(studipUrl.getPath().replaceAll("/*$", "") + apiPath)
303         .addParameter("token", studipToken)
304         .build();
305 
306     // Execute request
307     HttpGet get = new HttpGet(url);
308     get.setHeader("User-Agent", OC_USERAGENT);
309 
310     // Don't wait for responses indefinitely
311     RequestConfig config = RequestConfig.custom()
312         .setConnectTimeout(5000)
313         .setSocketTimeout(10000).build();
314 
315     try (CloseableHttpClient client = HttpClientBuilder.create().setDefaultRequestConfig(config).build();) {
316       try (CloseableHttpResponse resp = client.execute(get)) {
317         var statusCode = resp.getStatusLine().getStatusCode();
318         if (statusCode == 404) {
319           // Stud.IP does not know about the user
320           return null;
321         } else if (statusCode / 100 != 2) {
322           throw new IOException("HttpRequest unsuccessful, reason: " + resp.getStatusLine().getReasonPhrase());
323         }
324 
325         // Parse response
326         BufferedReader reader = new BufferedReader(new InputStreamReader(resp.getEntity().getContent()));
327         JSONParser parser = new JSONParser();
328         Object obj = parser.parse(reader);
329 
330         // Check for errors
331         if (!(obj instanceof JSONObject)) {
332           throw new IOException("StudIP responded in unexpected format");
333         }
334 
335         JSONObject jObj = (JSONObject) obj;
336         if (jObj.containsKey("errors")) {
337           throw new IOException("Stud.IP returned an error: " + jObj.toJSONString());
338         }
339 
340         return jObj;
341       }
342     }
343   }
344 
345   /**
346    * {@inheritDoc}
347    * 
348    * @see org.opencastproject.security.api.CachingUserProviderMXBean#getCacheHitRatio()
349    */
350   @Override
351   public float getCacheHitRatio() {
352     if (requests.get() == 0) {
353       return 0;
354     }
355     return (float) (requests.get() - studipLoads.get()) / requests.get();
356   }
357 
358   @Override
359   public Iterator<User> findUsers(String query, int offset, int limit) {
360 
361     if (query == null) {
362       throw new IllegalArgumentException("Query must be set");
363     }
364 
365     if (query.endsWith("%")) {
366       query = query.substring(0, query.length() - 1);
367     }
368 
369     if (query.isEmpty()) {
370       return Collections.emptyIterator();
371     }
372 
373     List<User> users = new LinkedList<User>();
374     JaxbOrganization jaxbOrganization = JaxbOrganization.fromOrganization(organization);
375     JaxbUser queryUser = new JaxbUser(query, PROVIDER_NAME, jaxbOrganization, new HashSet<JaxbRole>());
376     users.add(queryUser);
377 
378     return users.iterator();
379   }
380 
381   @Override
382   public Iterator<User> getUsers() {
383     // We never enumerate all users
384     return Collections.emptyIterator();
385   }
386 
387   @Override
388   public void invalidate(String userName) {
389     cache.invalidate(userName);
390   }
391 
392   @Override
393   public long countUsers() {
394     // Not meaningful, as we never enumerate users
395     return 0;
396   }
397 
398   // RoleProvider methods
399 
400   @Override
401   public List<Role> getRolesForUser(String userName) {
402 
403     List<Role> roles = new LinkedList<Role>();
404 
405     // Don't answer for admin, anonymous or empty user
406     if ("admin".equals(userName) || "".equals(userName) || "anonymous".equals(userName)) {
407       logger.debug("we don't answer for {}", userName);
408       return roles;
409     }
410 
411     logger.debug("getRolesForUser({})", userName);
412 
413     User user = loadUser(userName);
414     if (user != null) {
415       logger.debug("Returning cached role set for {}", userName);
416       return new ArrayList<Role>(user.getRoles());
417     }
418 
419     // Not found
420     logger.debug("Return empty role set for {} - not found on Stud.IP", userName);
421     return new LinkedList<>();
422   }
423 
424   @Override
425   public Iterator<Role> findRoles(String query, Role.Target target, int offset, int limit) {
426     logger.debug("findRoles(query={} offset={} limit={})", query, offset, limit);
427 
428     // Don't return roles for users or groups
429     if (target == Role.Target.USER) {
430       return Collections.emptyIterator();
431     }
432 
433     if (query.endsWith("%")) {
434       query = query.substring(0, query.length() - 1);
435     }
436 
437     if (query.isEmpty()) {
438       return Collections.emptyIterator();
439     }
440 
441     // Roles list
442     List<Role> roles = new LinkedList<>();
443 
444     return roles.iterator();
445   }
446 
447 }