package com.augur.httpd.authorities;
import com.augur.httpd.Authority;
import com.augur.httpd.HttpRequest;
import com.augur.httpd.Session;
import java.util.Hashtable;
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.RPCRequest;
/**
* An LDAP implementation of a TriLevelAuthority for the JAWS web server.
* Its configuration requires a JSONObject containing the following:
*
{
"ldap.url": "ldap://localhost:10389",
"ldap.filter.user": "(uid={})",
"ldap.filter.admin":"(&(cn=Admins)(member={}))",
"ldap.filter.tech":"(&(cn=Techs)(member={}))",
"ldap.filter.guest":"(&(cn=Staff)(member={}))",
"ldap.context.users": "",
"ldap.context.groups": "",
"ldap.authentication": "none",
"ldap.principal": "",
"ldap.credentials": ""
}
*
*
* Copyright 2016 Augur Systems, Inc. All rights reserved.
*/
public class TriLevelLDAP extends TriLevel implements Authority
{
private static final boolean DEBUG = !true;
private static final String INITIAL_CONTEXT_FACTORY = "com.sun.jndi.ldap.LdapCtxFactory";
private final Hashtable LDAP_ENV = new Hashtable<>(); // can't use Map
private final SearchControls SEARCH_CONTROLS = new SearchControls(); // NOTE: Not a thread-safe class
private String CONTEXT_USERS, CONTEXT_GROUPS;
private String USER_FILTER, GRP_FILTER_ADMIN, GRP_FILTER_TECH, GRP_FILTER_GUEST;
private String PROVIDER_URL;
@Override public void init(JSONObject config) throws Exception
{
super.init(config);
// The LDAP server's address
this.PROVIDER_URL = config.optString("ldap.url", "ldap://localhost:389");
// Filter to find user's record; note that {} is replaced by the user-supplied ID
this.USER_FILTER = config.optString("ldap.filter.user","(uid={})");
// Optional path to start the user search somewhere more specific than the root
this.CONTEXT_USERS = config.optString("ldap.context.users","");
// Optional path to start the group search somewhere more specific than the root
this.CONTEXT_GROUPS = config.optString("ldap.context.groups","");
// Filters for our three application roles...
this.GRP_FILTER_ADMIN = config.optString("ldap.filter.admin","(&(cn=Admins)(member={}))");
this.GRP_FILTER_TECH = config.optString("ldap.filter.tech","(&(cn=Techs)(member={}))");
this.GRP_FILTER_GUEST = config.optString("ldap.filter.guest","(&(cn=Guests)(member={}))");
// Specify we want to use the LDAP protocol for this directory
LDAP_ENV.put(Context.INITIAL_CONTEXT_FACTORY, INITIAL_CONTEXT_FACTORY);
// URL to the LDAP server
LDAP_ENV.put(Context.PROVIDER_URL, PROVIDER_URL);
// "simple" if a read-only search account is required; "none" if anonymous
LDAP_ENV.put(Context.SECURITY_AUTHENTICATION, config.optString("ldap.authentication","simple"));
// The read-only account's DN, if not anonymous. e.g. "uid=admin,ou=system"
LDAP_ENV.put(Context.SECURITY_PRINCIPAL, config.optString("ldap.principal","cn=read-only,dc=augur,dc=com"));
// The read-only account's password, if not anonymous
LDAP_ENV.put(Context.SECURITY_CREDENTIALS, config.optString("ldap.credentials",""));
// Search filters will access their context's entire subtree...
SEARCH_CONTROLS.setSearchScope(SearchControls.SUBTREE_SCOPE);
}
/**
* Called by the user's login screen.
*
* @param httpRequest Contains the user-supplied ID and password
* @return a JSONObject containing the user's authenticated role
* @throws JSONException for any errors that should be displayed to the user.
*/
@Override public JSONObject login(HttpRequest httpRequest) throws Exception
{
System.out.println("[LDAP: login attempt from "+httpRequest.getRemoteAddress()+"]");
LEVEL level = LEVEL.NONE;
String pd = httpRequest.getPostData();
if (pd == null) throw new JSONException("Bad login request: null data");
RPCRequest rpcRequest = new RPCRequest(pd);
JSONObject params = rpcRequest.getRpcParams();
String dnFilter = USER_FILTER.replace("{}", params.optString("username",""));
String dn = null;
try { dn = findDN(dnFilter); }
catch (NamingException ne)
{
throw new Exception("Problem communicating with the LDAP server to validate your login.", ne);
}
if (dn != null)
{
try
{
// throws NamingException if user id/pw fail...
DirContext ctx = authenticate(dn, params.optString("password",""));
// Not sure if it's safer to search groups under user's login, or the real-only/anonymous user.
// try { ctx.close(); } catch (NamingException ne) { } // Close user's context
// ctx = new InitialDirContext(LDAP_ENV); // Reopen with basic search access
try
{
if (isGroupMember(ctx,GRP_FILTER_ADMIN.replace("{}", dn))) { level = LEVEL.ADMIN; }
else if (isGroupMember(ctx,GRP_FILTER_TECH.replace("{}", dn))) { level = LEVEL.TECH; }
else if (isGroupMember(ctx,GRP_FILTER_GUEST.replace("{}", dn))) { level = LEVEL.GUEST; }
System.out.println("[LDAP: authenticated user \""+dn+"\" for role: "+level+"]");
}
finally { try { ctx.close(); } catch (NamingException ne) { } }
}
// catch to log, but allow to return LEVEL.NONE, indicating a bad id/pw...
catch (javax.naming.AuthenticationException ae) { System.out.println(ae.getMessage()); }
catch (NamingException ne) { ne.printStackTrace(); }
}
else
{
System.out.println("[LDAP: user's DN not found with filter \""+dnFilter+"\"]");
}
// Return the user's authorized level (a.k.a. "role") for our app...
Session httpSession = httpRequest.getSession();
httpSession.put("auth-level", level);
try
{
JSONObject response = new JSONObject();
response.put("level", level.ordinal());
response.put("name", level.name());
return response;
}
catch (JSONException jsone) // shouldn't happen
{
System.out.println("Unexpected error creating successful login response: "+jsone);
return null; // no exception means success
}
}
/**
* Finds the unique distinguished name (DN) with the given filter.
*
* @param filter The String LDAP search filter
* @return The String distinguished name (DN); null if not found.
* @throws NamingException if more than one result is found
*/
public String findDN(String filter) throws NamingException
{
DirContext ctx = new InitialDirContext(LDAP_ENV); // throws NamingException
try
{
if (DEBUG) { System.out.println("Searching with filter="+filter+", under context base: \""+CONTEXT_USERS+"\""); }
NamingEnumeration results = ctx.search(CONTEXT_USERS, filter, SEARCH_CONTROLS); // throws NamingException
if (results.hasMore())
{
SearchResult searchResult = results.nextElement();
if (results.hasMore())
{
throw new NamingException("Unexpectedly found multiple records for that user.");
}
else { return searchResult.getNameInNamespace(); }
}
else { return null; }
}
finally { try { ctx.close(); } catch (NamingException ne) { } }
}
private DirContext authenticate(String dn, String pw) throws NamingException
{
if (DEBUG) { System.out.println("Authenticating with dn=\""+dn+"\", pw=\""+pw+"\""); }
Hashtable env = new Hashtable<>(LDAP_ENV);
if (dn!=null && pw!=null)
{
env.put(Context.SECURITY_AUTHENTICATION,"simple");
env.put(Context.SECURITY_PRINCIPAL, dn);
env.put(Context.SECURITY_CREDENTIALS, pw);
}
return new InitialDirContext(env); // throws NamingException if user id/pw fail
}
private boolean isGroupMember(DirContext ctx, String filter) throws NamingException
{
if (DEBUG) { System.out.println("Searching with filter "+filter+", under context base: \""+CONTEXT_GROUPS+"\""); }
NamingEnumeration results = ctx.search(CONTEXT_GROUPS, filter, SEARCH_CONTROLS); // throws NamingException
if (DEBUG) { System.out.println(" results.hasMore()? "+results.hasMore()); }
return results.hasMore();
}
}