/* ================================================================== * AuthenticationDataV2.java - 1/03/2017 8:41:00 PM * * Copyright 2007-2017 SolarNetwork.net Dev Team * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA * 02111-1307 USA * ================================================================== */ package net.solarnetwork.central.security.web; import java.io.IOException; import java.util.Arrays; import java.util.Calendar; import java.util.Enumeration; import java.util.GregorianCalendar; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.TimeZone; import javax.servlet.http.HttpServletRequest; import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.digest.DigestUtils; import org.springframework.security.authentication.BadCredentialsException; import net.solarnetwork.util.StringUtils; /** * Version 2 authentication token scheme based on HMAC-SHA256. * * Signing keys are treated valid for up to 7 days in the past from the time of * the signature calculation in {@link #computeSignatureDigest(String)}. * * @author matt * @version 1.0 * @since 1.8 */ public class AuthenticationDataV2 extends AuthenticationData { private static final int SIGNATURE_HEX_LENGTH = 64; public static final String TOKEN_COMPONENT_KEY_CREDENTIAL = "Credential"; public static final String TOKEN_COMPONENT_KEY_SIGNED_HEADERS = "SignedHeaders"; public static final String TOKEN_COMPONENT_KEY_SIGNATURE = "Signature"; private final String authTokenId; private final String signatureDigest; private final String signatureData; private final Set<String> signedHeaderNames; private final String[] sortedSignedHeaderNames; public AuthenticationDataV2(SecurityHttpServletRequestWrapper request, String headerValue) throws IOException { super(AuthenticationScheme.V2, request, headerValue); // the header must be in the form Credential=TOKEN-ID,SignedHeaders=x;y;z,Signature=HMAC-SHA1-SIGNATURE Map<String, String> tokenData = tokenStringToMap(headerValue); authTokenId = tokenData.get(TOKEN_COMPONENT_KEY_CREDENTIAL); if ( authTokenId == null || authTokenId.length() != AUTH_TOKEN_ID_LENGTH ) { throw new BadCredentialsException("Invalid " + TOKEN_COMPONENT_KEY_CREDENTIAL + " value"); } signatureDigest = tokenData.get(TOKEN_COMPONENT_KEY_SIGNATURE); if ( signatureDigest == null || signatureDigest.length() != SIGNATURE_HEX_LENGTH ) { throw new BadCredentialsException("Invalid " + TOKEN_COMPONENT_KEY_SIGNATURE + " value"); } String signedHeaders = tokenData.get(TOKEN_COMPONENT_KEY_SIGNED_HEADERS); signedHeaderNames = StringUtils.delimitedStringToSet(signedHeaders, ";"); if ( signedHeaderNames == null || signedHeaderNames.size() < 2 ) { // a minimum of Host + (Date | X-SN-Date) must be provided throw new BadCredentialsException( "Invalid " + TOKEN_COMPONENT_KEY_SIGNED_HEADERS + " value"); } sortedSignedHeaderNames = signedHeaderNames.toArray(new String[signedHeaderNames.size()]); for ( int i = 0; i < sortedSignedHeaderNames.length; i++ ) { sortedSignedHeaderNames[i] = sortedSignedHeaderNames[i].toLowerCase(); } Arrays.sort(sortedSignedHeaderNames); validateSignedHeaderNames(request); validateContentDigest(request); signatureData = computeSignatureData(computeCanonicalRequestData(request)); } private static Map<String, String> tokenStringToMap(final String headerValue) { if ( headerValue == null || headerValue.length() < 1 ) { return null; } final Map<String, String> map = new LinkedHashMap<String, String>(); final String delimitedString = headerValue + ','; int prevDelimIdx = 0; int delimIdx; int splitIdx; for ( delimIdx = delimitedString.indexOf(','); delimIdx >= 0; prevDelimIdx = delimIdx + 1, delimIdx = delimitedString.indexOf(',', prevDelimIdx) ) { String component = delimitedString.substring(prevDelimIdx, delimIdx); splitIdx = component.indexOf('='); if ( splitIdx > 0 ) { String componentKey = component.substring(0, splitIdx); String componentValue = component.substring(splitIdx + 1); map.put(componentKey, componentValue); } } return map; } @Override public String computeSignatureDigest(String secretKey) { // signing keys are valid for 7 days, so starting with today work backwards at most // 7 days to see if we get a match Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT")); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); String result = null; for ( int i = 0; i < 7; i += 1, cal.add(Calendar.DATE, -1) ) { final byte[] signingKey = computeSigningKey(secretKey, cal); String computed = Hex .encodeHexString(computeMACDigest(signingKey, signatureData, "HmacSHA256")); if ( computed.equals(signatureDigest) ) { return computed; } else if ( result == null ) { // save 1st result as one we return if nothing matches result = computed; } } return result; } private String formatSigningDate(Calendar cal) { int year = cal.get(Calendar.YEAR); int month = cal.get(Calendar.MONTH) + 1; int day = cal.get(Calendar.DATE); StringBuilder buf = new StringBuilder(); buf.append(year); if ( month < 10 ) { buf.append('0'); } buf.append(month); if ( day < 10 ) { buf.append('0'); } buf.append(day); return buf.toString(); } private byte[] computeSigningKey(String secretKey, Calendar cal) { /*- signing key is like: HMACSHA256(HMACSHA256("SNWS2"+secretKey, "20160301"), "snws2_request") */ String dateStr = formatSigningDate(cal); return computeMACDigest(computeMACDigest(AuthenticationScheme.V2.getSchemeName() + secretKey, dateStr, "HmacSHA256"), "snws2_request", "HmacSHA256"); } private String computeSignatureData(String canonicalRequestData) { /*- signature data is like: SNWS2-HMAC-SHA256\n 20170301T120000Z\n Hex(SHA256(canonicalRequestData)) */ return "SNWS2-HMAC-SHA256\n" + iso8601Date(getDate()) + "\n" + Hex.encodeHexString(DigestUtils.sha256(canonicalRequestData)); } private String computeCanonicalRequestData(SecurityHttpServletRequestWrapper request) throws IOException { // 1: HTTP verb StringBuilder buf = new StringBuilder(request.getMethod()).append('\n'); // 2: Canonical URI buf.append(request.getRequestURI()).append('\n'); // 3: Canonical query string appendQueryParameters(request, buf); // 4: Canonical headers appendHeaders(request, buf); // 5: Signed headers appendSignedHeaderNames(buf); // 6: Content SHA256 appendContentSHA256(request, buf); return buf.toString(); } private void appendContentSHA256(SecurityHttpServletRequestWrapper request, StringBuilder buf) throws IOException { byte[] digest = request.getContentSHA256(); buf.append(digest == null ? WebConstants.EMPTY_STRING_SHA256_HEX : Hex.encodeHexString(digest)); } private void appendSignedHeaderNames(StringBuilder buf) { boolean first = true; for ( String headerName : sortedSignedHeaderNames ) { if ( first ) { first = false; } else { buf.append(';'); } buf.append(headerName); } buf.append('\n'); } private void appendHeaders(HttpServletRequest request, StringBuilder buf) { for ( String headerName : sortedSignedHeaderNames ) { buf.append(headerName).append(':').append(nullSafeHeaderValue(request, headerName).trim()) .append('\n'); } } private void appendQueryParameters(HttpServletRequest request, StringBuilder buf) { Set<String> paramKeys = request.getParameterMap().keySet(); if ( paramKeys.size() < 1 ) { buf.append('\n'); return; } String[] keys = paramKeys.toArray(new String[paramKeys.size()]); Arrays.sort(keys); boolean first = true; for ( String key : keys ) { if ( first ) { first = false; } else { buf.append('&'); } buf.append(uriEncode(key)).append('=').append(uriEncode(request.getParameter(key))); } buf.append('\n'); } private void validateSignedHeaderNames(SecurityHttpServletRequestWrapper request) { // MUST include host if ( !signedHeaderNames.contains("host") ) { throw new BadCredentialsException( "The 'Host' HTTP header must be included in SignedHeaders"); } // MUST include one of Date or X-SN-Date if ( !(signedHeaderNames.contains(WebConstants.HEADER_DATE.toLowerCase()) || signedHeaderNames.contains("date")) ) { throw new BadCredentialsException( "One of the 'Date' or 'X-SN-Date' HTTP headers must be included in SignedHeaders"); } Enumeration<String> headerNames = request.getHeaderNames(); final String snHeaderPrefix = WebConstants.HEADER_PREFIX.toLowerCase(); while ( headerNames.hasMoreElements() ) { String headerName = headerNames.nextElement().toLowerCase(); // ALL X-SN-* headers must be included; also Content-Type, Content-MD5, Digest boolean mustInclude = (headerName.startsWith(snHeaderPrefix) || headerName.equals("content-type") || headerName.equals("content-md5") || headerName.equals("digest")); if ( mustInclude && !signedHeaderNames.contains(headerName) ) { throw new BadCredentialsException( "The '" + headerName + "' HTTP header must be included in SignedHeaders"); } } } @Override public String getAuthTokenId() { return authTokenId; } @Override public String getSignatureDigest() { return signatureDigest; } @Override public String getSignatureData() { return signatureData; } /** * Get the set of signed header names. * * @return The signed header names, or {@code null}. */ public Set<String> getSignedHeaderNames() { return signedHeaderNames; } }