// Copyright (c) 2014, SAS Institute Inc., Cary, NC, USA, All Rights Reserved
package com.sas.unravl.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.sas.unravl.ApiCall;
import com.sas.unravl.UnRAVL;
import com.sas.unravl.UnRAVLException;
import com.sas.unravl.annotations.UnRAVLAuthPlugin;
import com.sas.unravl.assertions.UnRAVLAssertionException;
import com.sas.unravl.generators.Binary;
import com.sas.unravl.generators.Text;
import com.sas.unravl.util.Json;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicHeader;
import org.apache.log4j.Logger;
/**
* An auth plugin which authenticates with Central Authentication Service. This
* authenticates to obtain a Ticket Granting Ticket (TGT) using the user's
* credentials provided by the {@link CredentialsProvider} which by default is
* the {@link NetrcCredentialsProvider}. This then uses that TGT to obtain a
* Service Ticket for the current UnRAVL script API. Optionally, credentials may
* be enclosed directly in the "cas" element.
* <p>
* This auth element is specified via
*
* <pre>
* "auth" : { "cas" : <em>logon-URL</em> }
* "auth" : { "cas" : <em>logon-URL</em>, "login" : "myUserId" }
* "auth" : { "cas" : <em>logon-URL</em>, "login" : "myUserId", "password" : "mySecret" }
* "auth" : { "cas" : <em>logon-URL</em>, "mock" : <em>boolean-value</em>
* </pre>
*
* where <em>logon-URL</em> is a string containing the URL of the ticket
* granting ticket authentication API, such as
*
* <pre>
* { "cas" : "http://www.example.com/SASLogon/v1/tickets" }
* </pre>
* <p>
* The service ticket is appended as a query parameter to the end of the URI as
* <code>&ticket=<<em>service-ticket</em>></code> or
* <code>?ticket=<<em>service-ticket</em>></code> as needed. The TGT
* location is added to the environment as
* <code><<em>hostname</em>>.TGT</code> where hostname is taken from the
* logon-URL value in the JSON specification
* <p>
* If mock is true, this auth object will create a mock service ticket.
*
* @author DavidBiesack@sas.com
*/
@UnRAVLAuthPlugin("cas")
public class CentralAuthenticationServiceAuth extends BaseUnRAVLAuth {
private String logonUrl;
private boolean mock; // JSON spec contains "mock" : true, then mock out the
// CAS responses and create a fake ?ticket=
// parameter
private ByteArrayOutputStream responseBody;
private static final Logger logger = Logger
.getLogger(CentralAuthenticationServiceAuth.class);
@Override
public void authenticate(UnRAVL script, ObjectNode casAuthSpec, ApiCall call)
throws UnRAVLException {
super.authenticate(script, casAuthSpec, call);
authenticateAndCreateServiceTicket(casAuthSpec);
}
void authenticateAndCreateServiceTicket(ObjectNode auth)
throws UnRAVLException {
try {
if (getScript().getURI() == null || getScript().getMethod() == null)
throw new UnRAVLException(
"cas auth requires an HTTP method and URI");
// Note: The URI should already be expanded at this point
String location = getCall().getURI();
URI uri = new URI(location);
if (uri.toString().contains("?ticket=")
|| uri.toString().contains("&ticket=")) {
logger.warn("Warning: uri already contains ticket= "
+ uri.toString());
return;
}
long start = System.currentTimeMillis();
JsonNode logon = Json.firstFieldValue(getScriptlet());
if (logon == null || !(logon instanceof TextNode))
throw new UnRAVLException("cas auth requires a logon element.");
if (getScriptlet().get("mock") != null)
mock = getScriptlet().get("mock").booleanValue();
logonUrl = getScript().expand(logon.textValue());
String tgtLocation = logon(new URI(logonUrl), auth);
String st = serviceTicket(tgtLocation, uri);
getScript().bind("casAuth.ST", st);
String ticketedUri = serviceTicket(location, st);
getCall().setURI(ticketedUri);
long end = System.currentTimeMillis();
logger.trace("CAS authentication took " + (end - start) + "ms");
} catch (URISyntaxException e) {
new UnRAVLException(e.getMessage(), e);
} catch (IOException e) {
new UnRAVLException(e.getMessage(), e);
}
}
/**
* Save the TGT for this URI's host in the environment, so subsequent
* casAuth preconditions do not have to reauthenticate. The most recently
* acquired TGT is also stored in the environment as "{user}.{host}.TGT",
* and also to "casauth.TGT", which enables logging out by performing a
* DELETE on the TGT.
*
* @param tgt
* the ticket granting ticket
* @param uri
* the current call's URI
* @param user
* the userid
*/
private void bindTGT(String tgt, URI uri, String user) {
String host = uri.getHost();
getScript().bind(user + "." + host + "." + "TGT", tgt);
getScript().bind("casAuth.TGT", tgt); // This allows logout via DELETE
// to the TGT
}
private String serviceTicket(String location, String serviceTicket)
throws UnsupportedEncodingException {
String encodedTicket = Text.urlEncode(serviceTicket);
logger.info("\"cas\" authentication added service ticket= query parameter to request URL.");
if (location.indexOf('?') == -1)
return location + "?ticket=" + encodedTicket;
else
return location + "&ticket=" + encodedTicket;
}
private String serviceTicket(String tgt, URI uri) throws UnRAVLException,
URISyntaxException, ClientProtocolException, IOException {
if (mock)
return "ST-18-umUeNL4yUkWHES2VdtKki5mFzatga43kNNCe3niguLWaUxl1aK-cas";
CloseableHttpClient httpclient = HttpClients.createDefault();
try {
// TODO: make this call via UnRAVL, not HttpPost
HttpPost post = new HttpPost();
post.setURI(new URI(tgt));
Header requestHeaders[] = new Header[] { new BasicHeader(
"Content-Type", "text/plain") };
post.setHeaders(requestHeaders);
String body = "service=" + Text.urlEncode(uri.toString());
HttpEntity entity = new StringEntity(body);
post.setEntity(entity);
ResponseHandler<HttpResponse> responseHandler = new CasAuthResponseHandler();
HttpResponse response = httpclient.execute(post, responseHandler);
// TODO: If we get back a response that indicates a timed out
// TGT, we should login again.
int status = response.getStatusLine().getStatusCode();
if (status != 200)
throw new UnRAVLException("Cannot get Service Ticket for "
+ uri + ", response returned " + status);
String st = Text.utf8ToString(responseBody.toByteArray());
return st;
} finally {
httpclient.close();
}
}
private String logon(URI logonURI, ObjectNode auth) throws UnRAVLException,
URISyntaxException, ClientProtocolException, IOException {
if (mock)
return "https://sasserver:port/SASLogon/v1/tickets/TGT-18-umUeNL4yUkWHES2VdtKki5mFzatga43kNNCe3niguLWaUxl1aK-cas";
String host = logonURI.getHost();
CredentialsProvider cp = getScript().getRuntime().getPlugins()
.getCredentialsProvider();
cp.setRuntime(getScript().getRuntime());
HostCredentials credentials = cp.getHostCredentials(host, auth, false);
if (credentials == null)
throw new UnRAVLAssertionException("No CAS credentials for host "
+ host);
String user = credentials.getUserName();
String key = user + "." + host + ".TGT";
String tgt = null;
// See if the TGT is cached for this user/host
// Note that this risks a TGT timeout
if (getCall().bound(key)) {
Object tgto = getCall().getVariable(key);
if (tgto instanceof String) {
tgt = (String) getCall().getVariable(key);
logger.info("Using cached CAS TGT " + tgt);
return tgt;
}
}
CloseableHttpClient httpclient = HttpClients.createDefault();
try {
HttpPost post = new HttpPost();
Header requestHeaders[] = new Header[] { new BasicHeader(
"Content-Type", "application/x-www-form-urlencoded") };
String u = Text.urlEncode(user);
String p = Text.urlEncode(credentials.getPassword());
String body = String.format("username=%s&password=%s", u, p);
// security: don't hold onto credentials in memory
credentials.clear();
credentials = null;
p = null;
post.setURI(logonURI);
post.setHeaders(requestHeaders);
post.setEntity(new StringEntity(body));
ResponseHandler<HttpResponse> responseHandler = new CasAuthResponseHandler();
HttpResponse response = httpclient.execute(post, responseHandler);
// security: don't hold onto credentials in memory
body = null;
int status = response.getStatusLine().getStatusCode();
if (status != 201)
throw new UnRAVLException("Cannot login via " + logonURI
+ ", response: " + response.getStatusLine());
Header location = response.getFirstHeader("Location");
if (location == null)
throw new UnRAVLException("Cannot login via " + logonURI
+ ", no Location header returned.");
tgt = location.getValue();
bindTGT(tgt, logonURI, user);
return tgt;
} finally {
httpclient.close();
}
}
private class CasAuthResponseHandler implements
ResponseHandler<HttpResponse> {
@Override
public HttpResponse handleResponse(HttpResponse response)
throws ClientProtocolException, IOException {
if (response.getEntity() == null)
return response;
InputStream input = response.getEntity().getContent();
if (input != null) {
responseBody = new ByteArrayOutputStream();
Binary.copy(input, responseBody);
responseBody.close();
} else
responseBody = null;
return response;
}
}
}