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