/*******************************************************************************
* Copyright 2014 Miami-Dade County
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
******************************************************************************/
package org.sharegov.cirm.rest;
import static org.sharegov.cirm.OWL.dataFactory;
import static org.sharegov.cirm.OWL.fullIri;
import static org.sharegov.cirm.utils.GenUtils.dbg;
import static org.sharegov.cirm.utils.GenUtils.ko;
import static org.sharegov.cirm.utils.GenUtils.ok;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import mjson.Json;
import org.semanticweb.owlapi.model.IRI;
import org.semanticweb.owlapi.model.OWLIndividual;
import org.semanticweb.owlapi.model.OWLObjectProperty;
import org.sharegov.cirm.AutoConfigurable;
import org.sharegov.cirm.OWL;
import org.sharegov.cirm.Refs;
import org.sharegov.cirm.StartUp;
import org.sharegov.cirm.owl.Model;
import org.sharegov.cirm.owl.OWLObjectPropertyCondition;
import org.sharegov.cirm.user.UserProvider;
import org.sharegov.cirm.utils.ThreadLocalStopwatch;
/**
*
* <p>
* Main entry point for user management - authentication, profile retrieval, access policies.
* </p>
*
* @author Syed Abbas
* @author Tom Hilpold
* @author Borislav Iordanov
*
*/
@Path("users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UserService extends RestService implements AutoConfigurable
{
public static final IRI DEFAULT_STOP_EXPANSION_CONDITION_IRI1 = Model.upper("Department");
public static final IRI DEFAULT_STOP_EXPANSION_CONDITION_IRI2 = Model.upper("Divison");
public static final IRI DEFAULT_STOP_EXPANSION_CONDITION_IRI3 = Model.upper("hasDivision");
public static final IRI DEFAULT_STOP_EXPANSION_CONDITION_IRI4 = Model.upper("hasObject");
public static final String CIRM_ADMIN = Model.upper("CirmAdmin").toString();
private final OWLObjectPropertyCondition stopExpansionCondition = getStopExpansionCondition();
private OWLObjectPropertyCondition getStopExpansionCondition()
{
Set<OWLObjectProperty> stopExpansionProps = new HashSet<OWLObjectProperty>();
stopExpansionProps.add(OWL.objectProperty(DEFAULT_STOP_EXPANSION_CONDITION_IRI1));
stopExpansionProps.add(OWL.objectProperty(DEFAULT_STOP_EXPANSION_CONDITION_IRI2));
stopExpansionProps.add(OWL.objectProperty(DEFAULT_STOP_EXPANSION_CONDITION_IRI3));
stopExpansionProps.add(OWL.objectProperty(DEFAULT_STOP_EXPANSION_CONDITION_IRI4));
return new OWLObjectPropertyCondition(stopExpansionProps);
}
private Json desc = Json.object();
private static volatile Map<String, UserProvider> providerMap = new HashMap<String, UserProvider>();
private List<String> orderedProviders()
{
ArrayList<String> L = new ArrayList<String>(desc.at("hasUserBase", Json.object()).asJsonMap().keySet());
Collections.sort(L, new Comparator<String>() {
public int compare(String left, String right)
{
int x = desc.at("hasUserBase").at(left).at("hasOrdinal", Integer.MAX_VALUE).asInteger();
int y = desc.at("hasUserBase").at(right).at("hasOrdinal", Integer.MAX_VALUE).asInteger();
return x - y;
}
});
return L;
}
private String authenticateProvider()
{
return desc.at("authenticatesWith").at("hasName").asString();
}
private UserProvider provider(String name)
{
synchronized (providerMap)
{
UserProvider provider = providerMap.get(name);
if (provider != null)
return provider;
if (!desc.at("hasUserBase").has(name))
return null;
String classname = desc.at("hasUserBase").at(name).at("hasImplementation").at("iri").asString().split("#")[1];
try
{
provider = (UserProvider)Class.forName(classname).newInstance();
//Autoconfigure is not part of the object initialisation
//without synchronization, variables set during autoconfigure might not be readable by other threads.
synchronized(provider)
{
if (provider instanceof AutoConfigurable)
((AutoConfigurable)provider).autoConfigure(desc.at("hasUserBase").at(name));
}
providerMap.put(name, provider);
return provider;
}
catch (Exception ex)
{
throw new RuntimeException(ex);
}
}
}
private Json getAccessPolicies(Json groups)
{
if (!groups.isArray())
throw new IllegalArgumentException("Expected Array of cirmusergroups. e.g. legacy:311..");
Json cirmUserGroupsWithAccessPolicies = Json.array();
for (Json iri : groups.asJsonList())
{
OWLIndividual group = dataFactory().getOWLNamedIndividual(fullIri(iri.asString()));
//Here we need to make sure that the serialization stops at e.g.
//individuals that are the objects of an AccessPolicy!
Json groupWithAccessPolicies = OWL.toJSON(group, stopExpansionCondition);
cirmUserGroupsWithAccessPolicies.add(groupWithAccessPolicies);
}
//Array of cirm groups with all access policy information serialized.
return cirmUserGroupsWithAccessPolicies; //userdata.set("cirmusergroups", cirmUserGroupsWithAccessPolicies);
}
private Json prepareReturn(Json user)
{
if (user.isArray())
{
for (Json u : user.asJsonList())
prepareReturn(u);
}
else
{
user.delAt("hasPassword");
// TODO: can we get rid of this? the fear that somewhere on the client
// it is being used, but it shouldn't be.
if (user.has("hasUsername"))
user.set("username", user.at("hasUsername"));
}
return user;
}
public void autoConfigure(Json config)
{
this.desc = config;
}
/**
* <p>
* This is a general method to retrieve information about a particular user.
* Because it's expensive to fill out all information we can get about a user,
* the request is a more complex object that specifies what is to be
* provided. In this way, a client can request all that is needed and only
* that which is needed in a single network round-trip.
* </p>
* <p>
* The basic profile (first name, email etc.) is returned regardless. Here are the
* expected properties of the JSON <code>request</code> parameter that control
* what else is returned:
* <ul>
* <li>username - mandatory...of course</li>
* <li>groups - true/false whether to include the list of groups the user belongs to</li>
* <li>access - true/false whether to include the access policies for this user</li>
* </ul>
* </p>
*
* @param request
* @return
*/
@POST
@Path("/profile")
public Json userProfile(Json request)
{
try
{
if (!request.isObject() || !request.has("username"))
return ko("bad request.");
if (!request.has("provider") || request.is("provider", ""))
request.set("provider", desc.at("authenticatesWith").at("hasName"));
UserProvider providerImpl = provider(request.at("provider").asString());
Json profile = providerImpl.get(request.at("username").asString());
if (profile.isNull()) return ko("No profile");
if (request.is("groups", true) || request.is("access", true))
profile.set("groups", providerImpl.findGroups(request.at("username").asString()));
if (request.is("access", true))
profile.set("access", getAccessPolicies(profile.at("groups")));
return ok().set("profile", prepareReturn(profile));
}
catch (Throwable t)
{
if (!"unavailable".equals(t.getMessage())) // error would have already been reported in the logs
t.printStackTrace(System.err);
return ko(t.getMessage());
}
}
/**
* <p>
* Authenticate within a given realm (user provider).
* </p>
*
* @param form
* @return
*/
@POST
@Path("/authenticate")
public Json authenticate(Json form)
{
if (!form.has("provider") || form.is("provider", ""))
form.set("provider", desc.at("authenticatesWith").at("hasName"));
if (form.is("provider", authenticateProvider()))
{
if (!form.has("password") || form.is("password", ""))
return ko("Please provide a password.");
Json userdata = userProfile(form);
if (userdata.is("error", "No profile"))
return ko("User not found or invalid password.");
else if (!userdata.is("ok", true))
return userdata;
else if (!StartUp.getConfig().is("ignorePasswords", true))
{
if (!provider(form.at("provider").asString()).authenticate(
userdata.at("profile").at("hasUsername").asString(),
form.at("password").asString()))
return ko("User not found or invalid password.");
}
if (dbg())
{
String msg = (userdata.at("profile").has("hasUsername"))? userdata.at("profile").at("hasUsername").asString() : "Unknown";
msg += " | lastname: " + (userdata.at("profile").at("lastName", " no lastname")).toString();
msg += "\r\n | groups: " + (userdata.at("profile").at("groups", " no groups")).toString() + "\r\n";
ThreadLocalStopwatch.getWatch().time("Auth success: " + msg);
ThreadLocalStopwatch.dispose();
}
return ok().set("user", prepareReturn(userdata.at("profile")));
}
// other realms/providers...
else
return ko("Unknown realm");
}
/**
* Consumes an array of group names and augments those groups with the corresponding access policies.
* @param groups An array of names of groups.
* @return
*/
@POST
@Path("/accesspolicies")
public Json accessPolicies(Json groups)
{
groups = getAccessPolicies(groups);
if (!groups.asList().isEmpty() && groups.at(0).has("hasAccessPolicy"))
return ok().set("cirmusergroups", groups);
else
return ko("No Access policies are available for user.");
}
@GET
@Path("search")
public Json search(@QueryParam("id") String id,
@QueryParam("name") String searchString,
@QueryParam("providers") String providers)
{
if(id != null && !id.isEmpty())
{
return Json.array().add(searchUserById(id));
}
Json resultList = Json.array();
final int maxResults = 15;
try
{
if (searchString == null || searchString.length() == 0)
return null;
else
searchString = searchString.trim();
Json user = Json.object();
String name = searchString;
name = name.trim();
int idx;
//Parse search string
if ( (idx = name.indexOf(',')) > -1)
{ //Miller, Bob
user.set("LastName", name.substring(0, idx).trim());
user.set("FirstName", name.substring(idx+1).trim());
}
else if ( (idx = name.indexOf(' ')) > -1)
{ //Bob Miller
user.set("LastName", name.substring(idx+1).trim());
user.set("FirstName", name.substring(0, idx).trim());
}
else
{ //Miller
user.set("LastName", name);
}
if (user.is("FirstName", ""))
user.delAt("FirstName");
if (user.is("LastName", ""))
user.delAt("LastName");
if (user.asJsonMap().size() > 0)
{
Collection<String> P = providers != null ? Arrays.asList(providers.split(",")) : orderedProviders();
for (String providerName : P)
resultList.with(searchProvider(providerName, user, maxResults));
}
}
catch (Exception e)
{
e.printStackTrace();
return ko(e);
}
return prepareReturn(resultList);
}
/**
* <p>
* Searches a user by ID. If multiple realms are configured, each will be tried
* according to their ordinal number configuration. Only the first found is returned.
* </p>
*/
public Json searchUserById(String id)
{
if (id == null || id.length() == 0)
return Json.array();
for (String providerName : orderedProviders())
{
UserProvider P = provider(providerName);
Json user = P.get(id);
if (!user.isNull())
return user;
}
return Json.nil();
}
public Json searchProvider(String name, Json prototype, int maxResults)
{
UserProvider provider = provider(name);
if (provider == null)
throw new RuntimeException("Unknown user realm " + name);
return provider.find(prototype, maxResults);
}
@GET
@Path("{provider}/{id}")
@Produces("application/json")
public Json getUserJson(@PathParam(value = "provider") String provider, @PathParam(value = "id") String id)
{
UserProvider providerImpl = provider(provider);
if (providerImpl == null)
return ko("Unknown realm " + provider);
return prepareReturn(providerImpl.get(id));
}
/**
* <p>
* Retrieve full user information given a user id (a.k.a. username). If
* there are multiple user backing stores configured, information from each
* will be aggregated. The provider with the highest priority will be used
* to provide based information, but then each separate provider is added
* as a property.
* </p>
* <p>
* For example, if you have an LDAP provider called "ldap" and a databse provider
* called "db", with the ldap provider being the default (high priority), you
* would get something that looks like <code>{ "hasUsername":id, "FirstName":"John",
* "ldap":{...all LDAP user attributes }, "db":{ all DB user attributes}}</code>
* </p>
* @param id
* @return
*/
@GET
@Path("{id}")
@Produces("application/json")
public Json getUserById(@PathParam("id") String id)
{
Json user = Json.object("userid", id);
List<String> plist = orderedProviders();
for (String providerName : plist)
{
UserProvider P = provider(providerName);
P.populate(user);
}
return ok().set("profile", prepareReturn(user));
}
public String getFullName(String userid)
{
if(userid == null || userid.isEmpty())
return "";
Json user = searchUserById(userid);
if (user.isNull())
return "";
else
return user.at("FirstName", "").asString() + " " + user.at("LastName", "").asString();
}
public UserService()
{
autoConfigure(Refs.owlJsonCache.resolve().individual(OWL.fullIri("UserService")).resolve());
}
}