/* Copyright (c) 2008 Google Inc.
*
* 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 com.google.gdata.client.http;
import com.google.gdata.util.common.base.CharEscapers;
import com.google.gdata.util.common.base.Charsets;
import com.google.gdata.util.common.base.StringUtil;
import com.google.gdata.util.common.io.Characters;
import com.google.gdata.util.common.util.Base64;
import com.google.gdata.util.AuthenticationException;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.util.Map;
/**
* Utility functions to support AuthSub (Account Authentication for Web
* Applications).
*
*
*/
public class AuthSubUtil {
// The default protocol to use for AuthSub communication
private static final String DEFAULT_PROTOCOL = "https";
// The default domain to use for AuthSub communication
private static final String DEFAULT_DOMAIN = "www.google.com";
// Secure random number generator to sign requests
private static final SecureRandom RANDOM = new SecureRandom();
/**
* Signature algorithms supported by AuthSub to sign the requests.
*/
private enum SignatureAlgorithm {
DSA_SHA1("dsa-sha1", "SHA1withDSA"),
RSA_SHA1("rsa-sha1", "SHA1withRSA");
SignatureAlgorithm(String authSubName, String jcaName) {
this.authSubName = authSubName;
this.jcaName = jcaName;
}
@Override
public String toString() { return jcaName; }
public String getAuthSubName() { return authSubName; }
public String getJCAName() { return jcaName; }
private final String authSubName;
private final String jcaName;
}
/**
* Creates the request URL to be used to retrieve an AuthSub token.
* <p>
* On success, the user will be redirected to the next URL with the
* AuthSub token appended to the URL. Use {@link #getTokenFromReply(String)}
* to retrieve the token from the reply.
*
* @param nextUrl the URL to redirect to on successful token retrieval
* @param scope the scope of the requested AuthSub token
* @param secure <code>true</code> if the token will be used securely
* @param session <code>true</code> if the token will be exchanged for a
* session cookie
* @return the URL to be used to retrieve the AuthSub token
*/
public static String getRequestUrl(String nextUrl,
String scope,
boolean secure,
boolean session) {
return getRequestUrl(DEFAULT_PROTOCOL, DEFAULT_DOMAIN, nextUrl, scope,
secure, session);
}
/**
* Creates the request URL to be used to retrieve an AuthSub token.
* <p>
* On success, the user will be redirected to the next URL with the
* AuthSub token appended to the URL. Use {@link #getTokenFromReply(String)}
* to retrieve the token from the reply.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @param nextUrl the URL to redirect to on successful token retrieval
* @param scope the scope of the requested AuthSub token
* @param secure <code>true</code> if the token will be used securely
* @param session <code>true</code> if the token will be exchanged for a
* session cookie
* @return the URL to be used to retrieve the AuthSub token
*/
public static String getRequestUrl(String protocol,
String domain,
String nextUrl,
String scope,
boolean secure,
boolean session) {
StringBuffer url = new StringBuffer(protocol).append("://");
url.append(domain).append("/accounts/AuthSubRequest");
addParameter(url, "next", nextUrl);
addParameter(url, "scope", scope);
addParameter(url, "secure", secure ? "1" : "0");
addParameter(url, "session", session ? "1" : "0");
return url.toString();
}
/**
* Creates the request URL to be used to retrieve an AuthSub token for
* hosted applications.
* <p>
* On success, the user will be redirected to the next URL with the
* AuthSub token appended to the URL. Use {@link #getTokenFromReply(String)}
* to retrieve the token from the reply.
*
* @param hostedDomain hosted domain name, for example
* <code>mydomain.example.com</code>
* @param nextUrl the URL to redirect to on successful token retrieval
* @param scope the scope of the requested AuthSub token
* @param secure <code>true</code> if the token will be used securely
* @param session <code>true</code> if the token will be exchanged for a
* session cookie
* @return the URL to be used to retrieve the AuthSub token
*/
public static String getRequestUrl(String hostedDomain,
String nextUrl,
String scope,
boolean secure,
boolean session) {
return getRequestUrl(DEFAULT_PROTOCOL, DEFAULT_DOMAIN, hostedDomain,
nextUrl, scope, secure, session);
}
/**
* Creates the request URL for to be used to retrieve an AuthSub token for
* hosted applications.
* <p>
* On success, the user will be redirected to the next URL with the
* AuthSub token appended to the URL. Use {@link #getTokenFromReply(String)}
* to retrieve the token from the reply.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @param hostedDomain hosted domain name, for example
* <code>mydomain.example.com</code>
* @param nextUrl the URL to redirect to on successful token retrieval
* @param scope the scope of the requested AuthSub token
* @param secure <code>true</code> if the token will be used securely
* @param session <code>true</code> if the token will be exchanged for a
* session cookie
* @return the URL to be used to retrieve the AuthSub token
*/
public static String getRequestUrl(String protocol,
String domain,
String hostedDomain,
String nextUrl,
String scope,
boolean secure,
boolean session) {
StringBuffer url = new StringBuffer(
getRequestUrl(protocol, domain, nextUrl, scope, secure, session));
addParameter(url, "hd", hostedDomain);
return url.toString();
}
/**
* Parses and returns the AuthSub token returned by Google on a successful
* AuthSub login request. The token will be appended as a query parameter
* to the next URL specified while making the AuthSub request.
*
* @param url the redirected-to next URL with the token
* @return the AuthSub token returned by Google
*/
public static String getTokenFromReply(URL url) {
return getTokenFromReply(url.getQuery());
}
/**
* Parses and returns the AuthSub token returned by Google on a successful
* AuthSub login request. The token will be appended as a query parameter
* to the next URL specified while making the AuthSub request.
*
* @param queryString the query portion of the redirected-to URL containing
* the token
* @return the AuthSub token returned by Google
*/
public static String getTokenFromReply(String queryString) {
// Parse the query parameters
Map<String, String> params =
StringUtil.string2Map(queryString, "&", "=", true /*stripEntry*/);
params = StringUtil.lowercaseKeys(params);
return params.get("token");
}
/**
* Retrieves the private key from the specified keystore.
*
* @param keystore the path to the keystore file
* @param keystorePass the password that protects the keystore file
* @param keyAlias the alias under which the private key is stored
* @param keyPass the password protecting the private key
* @return the private key from the specified keystore
* @throws GeneralSecurityException if the keystore cannot be loaded
* @throws IOException if the file cannot be accessed
*/
public static PrivateKey getPrivateKeyFromKeystore(String keystore,
String keystorePass,
String keyAlias,
String keyPass)
throws IOException, GeneralSecurityException {
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
FileInputStream keyStream = null;
try {
keyStream = new FileInputStream(keystore);
keyStore.load(keyStream, keystorePass.toCharArray());
return (PrivateKey) keyStore.getKey(keyAlias, keyPass.toCharArray());
} finally {
if (keyStream != null) {
keyStream.close();
}
}
}
/**
* Exchanges the one time use token returned in the URL for a session
* token.
* <p>
* If the <code>key</code> is non-null, the token will be used securely
* and the request to make the exchange will be signed.
*
* @param onetimeUseToken the one time use token sent by Google in the URL
* @param key the private key to sign the request
* @return the session token. <code>null</code> if the request failed
* @throws IOException if error in writing/reading the request
* @throws GeneralSecurityException if error in signing the request
* @throws AuthenticationException if one time use token is rejected
*/
public static String exchangeForSessionToken(String onetimeUseToken,
PrivateKey key)
throws IOException, GeneralSecurityException, AuthenticationException {
return exchangeForSessionToken(DEFAULT_PROTOCOL, DEFAULT_DOMAIN,
onetimeUseToken, key);
}
/**
* Exchanges the one time use token returned in the URL for a session
* token.
* <p>
* If the <code>key</code> is non-null, the token will be used securely
* and the request to make the exchange will be signed.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @param onetimeUseToken the one time use token sent by Google in the URL
* @param key the private key to sign the request
* @return the session token. <code>null</code> if the request failed
* @throws IOException if error in writing/reading the request
* @throws GeneralSecurityException if error in signing the request
* @throws AuthenticationException if one time use token is rejected
*/
public static String exchangeForSessionToken(String protocol,
String domain,
String onetimeUseToken,
PrivateKey key)
throws IOException, GeneralSecurityException, AuthenticationException {
String sessionUrl = getSessionTokenUrl(protocol, domain);
URL url = new URL(sessionUrl);
HttpURLConnection httpConn = (HttpURLConnection) url.openConnection();
String header = formAuthorizationHeader(onetimeUseToken, key, url, "GET");
httpConn.setRequestProperty("Authorization", header);
if (httpConn.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new AuthenticationException(httpConn.getResponseCode() + ": "
+ httpConn.getResponseMessage());
}
// Parse the response
String body =
Characters.toString(
new InputStreamReader(
httpConn.getInputStream(), Charsets.ISO_8859_1));
Map<String, String> parsedTokens =
StringUtil.string2Map(body, "\n", "=", true /*stripEntry*/);
parsedTokens = StringUtil.lowercaseKeys(parsedTokens);
return parsedTokens.get("token");
}
/**
* Retrieves information about the AuthSub token.
* <p>
* If the <code>key</code> is non-null, the token will be used securely
* and the request to revoke the token will be signed.
*
* @param token the AuthSub token for which to receive information
* @param key the private key to sign the request
* @throws IOException if error in writing/reading the request
* @throws GeneralSecurityException if error in signing the request
* @throws AuthenticationException if the token is rejected
* @return the token information in the form of a Map from the name of the
* attribute to the value of the attribute.
*/
public static Map<String, String> getTokenInfo(String token,
PrivateKey key)
throws IOException, GeneralSecurityException, AuthenticationException {
return getTokenInfo(DEFAULT_PROTOCOL, DEFAULT_DOMAIN, token, key);
}
/**
* Retrieves information about the AuthSub token.
* <p>
* If the <code>key</code> is non-null, the token will be used securely
* and the request to revoke the token will be signed.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @param token the AuthSub token for which to receive information
* @param key the private key to sign the request
* @throws IOException if error in writing/reading the request
* @throws GeneralSecurityException if error in signing the request
* @throws AuthenticationException if the token is rejected
* @return the token information in the form of a Map from the name of the
* attribute to the value of the attribute.
*/
public static Map<String, String> getTokenInfo(String protocol,
String domain,
String token,
PrivateKey key)
throws IOException, GeneralSecurityException, AuthenticationException {
String tokenInfoUrl = getTokenInfoUrl(protocol, domain);
URL url = new URL(tokenInfoUrl);
HttpURLConnection httpConn = (HttpURLConnection) url.openConnection();
String header = formAuthorizationHeader(token, key, url, "GET");
httpConn.setRequestProperty("Authorization", header);
if (httpConn.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new AuthenticationException(httpConn.getResponseCode() + ": "
+ httpConn.getResponseMessage());
}
String body =
Characters.toString(
new InputStreamReader(
httpConn.getInputStream(), Charsets.ISO_8859_1));
return StringUtil.string2Map(body.trim(), "\n", "=", true);
}
/**
* Revokes the specified token.
* <p>
* If the <code>key</code> is non-null, the token will be used securely
* and the request to revoke the token will be signed.
*
* @param token the AuthSub token to revoke
* @param key the private key to sign the request
* @throws IOException if error in writing/reading the request
* @throws GeneralSecurityException if error in signing the request
* @throws AuthenticationException if the token is rejected
*/
public static void revokeToken(String token,
PrivateKey key)
throws IOException, GeneralSecurityException, AuthenticationException {
revokeToken(DEFAULT_PROTOCOL, DEFAULT_DOMAIN, token, key);
}
/**
* Revokes the specified token.
* <p>
* If the <code>key</code> is non-null, the token will be used securely
* and the request to revoke the token will be signed.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @param token the AuthSub token to revoke
* @param key the private key to sign the request
* @throws IOException if error in writing/reading the request
* @throws GeneralSecurityException if error in signing the request
* @throws AuthenticationException if the token is rejected
*/
public static void revokeToken(String protocol,
String domain,
String token,
PrivateKey key)
throws IOException, GeneralSecurityException, AuthenticationException {
String revokeUrl = getRevokeTokenUrl(protocol, domain);
URL url = new URL(revokeUrl);
HttpURLConnection httpConn = (HttpURLConnection) url.openConnection();
String header = formAuthorizationHeader(token, key, url, "GET");
httpConn.setRequestProperty("Authorization", header);
if (httpConn.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new AuthenticationException(httpConn.getResponseCode() + ": "
+ httpConn.getResponseMessage());
}
}
/**
* Forms the AuthSub authorization header.
* <p>
* If the <code>key</code> is null, the token will be used in insecure mode.
* If the <code>key</code> is non-null, the token will be used securely and
* the header will contain a signature.
*
* @param token the AuthSub token to use in the header
* @param key the private key used to sign the request
* @param requestUrl the URL of the request being issued
* @param requestMethod the HTTP method being used to issue the request
* @return the authorization header
* @throws GeneralSecurityException if error occurs while creating signature
*/
public static String formAuthorizationHeader(String token,
PrivateKey key,
URL requestUrl,
String requestMethod)
throws GeneralSecurityException {
if (key == null) {
return String.format("AuthSub token=\"%s\"", token);
} else {
// Form signature for secure mode
long timestamp = System.currentTimeMillis() / 1000;
long nonce = RANDOM.nextLong();
String dataToSign =
String.format("%s %s %d %s", requestMethod, requestUrl.toExternalForm(),
timestamp, unsignedLongToString(nonce));
SignatureAlgorithm sigAlg = getSigAlg(key);
byte[] signature = sign(key, dataToSign, sigAlg);
String encodedSignature = Base64.encode(signature);
return String.format("AuthSub token=\"%s\" data=\"%s\" sig=\"%s\" " +
"sigalg=\"%s\"", token, dataToSign, encodedSignature,
sigAlg.getAuthSubName());
}
}
/**
* Adds the query parameter with the given name and value to the URL.
*/
private static void addParameter(StringBuffer url,
String name,
String value) {
name = CharEscapers.uriEscaper().escape(name);
value = CharEscapers.uriEscaper().escape(value);
// Make sure the url currently ends with the correct delimiter
if (url.indexOf("?") == -1) {
url.append('?');
} else {
switch (url.charAt(url.length() - 1)) {
case '?':
case '&':
break;
default:
url.append('&');
}
}
// Append the name/value pair
url.append(name).append('=').append(value);
}
/**
* Signs the data with the given key and the provided algorithm.
*/
private static byte[] sign(PrivateKey key,
String data,
SignatureAlgorithm algorithm)
throws GeneralSecurityException {
Signature signature = Signature.getInstance(algorithm.getJCAName());
signature.initSign(key);
signature.update(data.getBytes());
return signature.sign();
}
/**
* Returns the signature algorithm to be used for the provided private key.
*/
private static SignatureAlgorithm getSigAlg(PrivateKey key) {
String algorithm = key.getAlgorithm();
if ("dsa".equalsIgnoreCase(algorithm)) {
return SignatureAlgorithm.DSA_SHA1;
} else if ("rsa".equalsIgnoreCase(algorithm)) {
return SignatureAlgorithm.RSA_SHA1;
} else {
throw new IllegalArgumentException("Unknown algorithm in private key.");
}
}
/**
* Returns the URL to use to exchange the one-time-use token for
* a session token.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @return the URL to exchange for the session token
*/
private static String getSessionTokenUrl(String protocol,
String domain) {
return protocol + "://" + domain + "/accounts/AuthSubSessionToken";
}
/**
* Returns the URL that handles token revocation.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @return the URL that handles token revocation.
*/
private static String getRevokeTokenUrl(String protocol,
String domain) {
return protocol + "://" + domain + "/accounts/AuthSubRevokeToken";
}
/**
* Returns the URL that handles token revocation.
*
* @param protocol the protocol to use to communicate with the server
* @param domain the domain at which the authentication server exists
* @return the URL that handles token revocation.
*/
private static String getTokenInfoUrl(String protocol,
String domain) {
return protocol + "://" + domain + "/accounts/AuthSubTokenInfo";
}
/**
* Treats the provided long as unsigned and converts it to a string.
*/
private static String unsignedLongToString(long value) {
if (value >= 0) {
return Long.toString(value);
} else {
// Split into two unsigned halves. As digits are printed out from
// the bottom half, move data from the top half into the bottom
// half
int max_dig = 20;
char[] cbuf = new char[max_dig];
int radix = 10;
int dst = max_dig;
long top = value >>> 32;
long bot = value & 0xffffffffl;
bot += (top % radix) << 32;
top /= radix;
while (bot > 0 || top > 0) {
cbuf[--dst] = Character.forDigit((int)(bot % radix), radix);
bot = (bot / radix) + ((top % radix) << 32);
top /= radix;
}
return new String(cbuf, dst, max_dig-dst);
}
}
}