Реализация Single Sign-On для SalesForce

в 16:17, , рубрики: java, SSO, Программирование, метки: ,

После 3 дней мучений и бесплодных попыток завести SSO для SalesForce спешу поделиться с сообществом правильным путем решения проблемы, дабы будущие поколения не тратили прорву драгоценного времени на битье головой об стену. Если интересно то прошу под кат.

О том что такое SSO (Single Sign-On) лучше расскажет вики, ибо я не являюсь мастером высокого слога.
В общем для проекта потребовалось организовать поддержку SSO для такого сервиса как SalesForce. Поскольку для общения между серверами используется язык SAML было решено помучить гугл поисками готовой реализации. После упорных поисков, для генерации SAML была найдена библиотека OpenSAML, что избавило вселенную от рождения очередного велосипеда.

В первую очередь сгенерируем сертификаты. Я использовал keytool из JDK но можно и с помощью OpenSSL:

keytool -genkey -keyalg RSA -alias SSO -keystore keystore
keytool -export -alias SSO -keystore keystore -file certificate.crt

После того как сгенерировали ключи нужно настроить SalesForce что бы разрешить логиниться, используя SSO. Наилучшая инструкция лежит на их вики — Single Sign-On with SAML on Force.com. Статья хорошая и большая но нам понадобится только один пункт «Configuring Force.com for SSO». Да и он с небольшими изменениями: Поскольку моя реализация передает имя пользователя в элементе NameIdentifier, то оставляем переключатели в их дефолтном состоянии: «Assertion contains User's salesforce.com username» и «User ID is in the NameIdentifier element of the Subject statement».

Поскольку была найдена парочка примеров для работы с библиотекой OpenSAML, быстренько был написан простой генератор, подходящий для тестовых нужд. После рабочего дня проведенного над вылизыванием кода был получен генератор генерирующий валидный SAML (по мнению валидатора SalesForce). Ниже приведен вылизанный код.

Поскольку планируется прикрутить поддержку и других сервисов кроме SalesForce то генератор разбит на несколько классов: общей части (SAMLResponseGenerator), реализации для SalesForce (SalesforceSAMLResponseGenerator) и программы для запуска всего этого безобразия:

SAMLResponseGenerator.java:

public abstract class SAMLResponseGenerator {

	private static XMLObjectBuilderFactory builderFactory = null;
	private String issuerId;
	private X509Certificate certificate;
	private PublicKey publicKey;
	private PrivateKey privateKey;

	protected abstract Assertion buildAssertion();

	public SAMLResponseGenerator(X509Certificate certificate, PublicKey publicKey, PrivateKey privateKey, String issuerId) {
		this.certificate = certificate;
		this.publicKey = publicKey;
		this.privateKey = privateKey;
		this.issuerId = issuerId;
	}

	public String generateSAMLAssertionString() throws UnrecoverableKeyException, InvalidKeyException, NoSuchAlgorithmException, CertificateException, KeyStoreException, NoSuchProviderException, SignatureException, MarshallingException, ConfigurationException, IOException, org.opensaml.xml.signature.SignatureException, UnmarshallingException {
		Response response = buildDefaultResponse(issuerId);

		Assertion assertion = buildAssertion();
		response.getAssertions().add(assertion);

		assertion = signObject(assertion, certificate, publicKey, privateKey);
		response = signObject(response, certificate, publicKey, privateKey);

		Element plaintextElement = marshall(response);

		return XMLHelper.nodeToString(plaintextElement);
	}

	@SuppressWarnings("unchecked")
	protected <T extends XMLObject> XMLObjectBuilder<T> getXMLObjectBuilder(QName qname)
			throws ConfigurationException {
		if (builderFactory == null) {
			// OpenSAML 2.3
			DefaultBootstrap.bootstrap();
			builderFactory = Configuration.getBuilderFactory();
		}
		return (XMLObjectBuilder<T>) builderFactory.getBuilder(qname);
	}

	protected <T extends XMLObject> T buildXMLObject(QName qname)
			throws ConfigurationException {
		XMLObjectBuilder<T> keyInfoBuilder = getXMLObjectBuilder(qname);
		return keyInfoBuilder.buildObject(qname);
	}

	protected Attribute buildStringAttribute(String name, String value)
			throws ConfigurationException {
		Attribute attrFirstName = buildXMLObject(Attribute.DEFAULT_ELEMENT_NAME);
		attrFirstName.setName(name);
		attrFirstName.setNameFormat(Attribute.UNSPECIFIED);

		// Set custom Attributes
		XMLObjectBuilder<XSString> stringBuilder = getXMLObjectBuilder(XSString.TYPE_NAME);
		XSString attrValueFirstName = stringBuilder.buildObject(AttributeValue.DEFAULT_ELEMENT_NAME, XSString.TYPE_NAME);
		attrValueFirstName.setValue(value);

		attrFirstName.getAttributeValues().add(attrValueFirstName);
		return attrFirstName;
	}

	private <T extends XMLObject> Element marshall(T object) throws MarshallingException {
		return Configuration.getMarshallerFactory().getMarshaller(object).marshall(object);
	}

	@SuppressWarnings("unchecked")
	private <T extends XMLObject> T unmarshall(Element element) throws MarshallingException, UnmarshallingException {
		return (T) Configuration.getUnmarshallerFactory().getUnmarshaller(element).unmarshall(element);
	}

	protected <T extends SignableSAMLObject> T signObject(T object, X509Certificate certificate, PublicKey publicKey, PrivateKey privateKey) throws MarshallingException, ConfigurationException, IOException, UnrecoverableKeyException, InvalidKeyException, NoSuchAlgorithmException, CertificateException, KeyStoreException, NoSuchProviderException, SignatureException, org.opensaml.xml.signature.SignatureException, UnmarshallingException {
		BasicX509Credential signingCredential = new BasicX509Credential();
        signingCredential.setEntityCertificate(certificate);
        signingCredential.setPrivateKey(privateKey);
        signingCredential.setPublicKey(publicKey);

        KeyInfo keyInfo = buildXMLObject(KeyInfo.DEFAULT_ELEMENT_NAME);
        X509Data x509Data = buildXMLObject(X509Data.DEFAULT_ELEMENT_NAME);
        org.opensaml.xml.signature.X509Certificate x509Certificate = buildXMLObject(org.opensaml.xml.signature.X509Certificate.DEFAULT_ELEMENT_NAME);
        x509Certificate.setValue(Base64.encodeBase64String(certificate.getEncoded()));
        x509Data.getX509Certificates().add(x509Certificate);
        keyInfo.getX509Datas().add(x509Data);

		Signature signature = buildXMLObject(Signature.DEFAULT_ELEMENT_NAME);
		signature.setSigningCredential(signingCredential);
		signature.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA1);
		signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
		signature.setKeyInfo(keyInfo);

		object.setSignature(signature);

		Element element = marshall(object);
		Signer.signObject(signature);

		return unmarshall(element);
	}

	protected Response buildDefaultResponse(String issuerId) {
		try {
			DateTime now = new DateTime();

			// Create Status
			StatusCode statusCode = buildXMLObject(StatusCode.DEFAULT_ELEMENT_NAME);
			statusCode.setValue(StatusCode.SUCCESS_URI);

			Status status = buildXMLObject(Status.DEFAULT_ELEMENT_NAME);
			status.setStatusCode(statusCode);
						
			// Create Issuer
			Issuer issuer = buildXMLObject(Issuer.DEFAULT_ELEMENT_NAME);
			issuer.setValue(issuerId);
			issuer.setFormat(Issuer.ENTITY);

			// Create the response
			Response response = buildXMLObject(Response.DEFAULT_ELEMENT_NAME);
			response.setIssuer(issuer);
			response.setStatus(status);
			response.setIssueInstant(now);
			response.setVersion(SAMLVersion.VERSION_20);

			return response;
		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}

	public String getIssuerId() {
		return issuerId;
	}

	public void setIssuerId(String issuerId) {
		this.issuerId = issuerId;
	}
}

SalesforceSAMLResponseGenerator.java:

public class SalesforceSAMLResponseGenerator extends SAMLResponseGenerator {

	private static final String SALESFORCE_LOGIN_URL = "https://login.salesforce.com";
	private static final String SALESFORCE_AUDIENCE_URI = "https://saml.salesforce.com";
	private static final Logger logger = Logger.getLogger(SalesforceSAMLResponseGenerator.class);
	private static final int maxSessionTimeoutInMinutes = 10;

	private String nameId;

	public SalesforceSAMLResponseGenerator(X509Certificate certificate, PublicKey publicKey, PrivateKey privateKey,
			String issuerId, String nameId) {
		super(certificate, publicKey, privateKey, issuerId);
		this.nameId = nameId;
	}

	@Override
	protected Assertion buildAssertion() {
		try {
			// Create the NameIdentifier
			NameID nameId = buildXMLObject(NameID.DEFAULT_ELEMENT_NAME);
			nameId.setValue(this.nameId);
			nameId.setFormat(NameID.EMAIL);

			// Create the SubjectConfirmation

			SubjectConfirmationData confirmationMethod = buildXMLObject(SubjectConfirmationData.DEFAULT_ELEMENT_NAME);
			DateTime notBefore = new DateTime();
			DateTime notOnOrAfter = notBefore.plusMinutes(maxSessionTimeoutInMinutes);
			confirmationMethod.setNotOnOrAfter(notOnOrAfter);
			confirmationMethod.setRecipient(SALESFORCE_LOGIN_URL);

			SubjectConfirmation subjectConfirmation = buildXMLObject(SubjectConfirmation.DEFAULT_ELEMENT_NAME);
			subjectConfirmation.setMethod(SubjectConfirmation.METHOD_BEARER);
			subjectConfirmation.setSubjectConfirmationData(confirmationMethod);

			// Create the Subject
			Subject subject = buildXMLObject(Subject.DEFAULT_ELEMENT_NAME);

			subject.setNameID(nameId);
			subject.getSubjectConfirmations().add(subjectConfirmation);

			// Create Authentication Statement
			AuthnStatement authnStatement = buildXMLObject(AuthnStatement.DEFAULT_ELEMENT_NAME);
			DateTime now2 = new DateTime();
			authnStatement.setAuthnInstant(now2);
			authnStatement.setSessionNotOnOrAfter(now2.plus(maxSessionTimeoutInMinutes));

			AuthnContext authnContext = buildXMLObject(AuthnContext.DEFAULT_ELEMENT_NAME);

			AuthnContextClassRef authnContextClassRef = buildXMLObject(AuthnContextClassRef.DEFAULT_ELEMENT_NAME);
			authnContextClassRef.setAuthnContextClassRef(AuthnContext.UNSPECIFIED_AUTHN_CTX);

			authnContext.setAuthnContextClassRef(authnContextClassRef);
			authnStatement.setAuthnContext(authnContext);

			Audience audience = buildXMLObject(Audience.DEFAULT_ELEMENT_NAME);
			audience.setAudienceURI(SALESFORCE_AUDIENCE_URI);

			AudienceRestriction audienceRestriction = buildXMLObject(AudienceRestriction.DEFAULT_ELEMENT_NAME);
			audienceRestriction.getAudiences().add(audience);

			Conditions conditions = buildXMLObject(Conditions.DEFAULT_ELEMENT_NAME);
			conditions.setNotBefore(notBefore);
			conditions.setNotOnOrAfter(notOnOrAfter);
			conditions.getConditions().add(audienceRestriction);

			// Create Issuer
			Issuer issuer = (Issuer) buildXMLObject(Issuer.DEFAULT_ELEMENT_NAME);
			issuer.setValue(getIssuerId());

			// Create the assertion
			Assertion assertion = buildXMLObject(Assertion.DEFAULT_ELEMENT_NAME);
			assertion.setIssuer(issuer);
			assertion.setID(UUID.randomUUID().toString());
			assertion.setIssueInstant(notBefore);
			assertion.setVersion(SAMLVersion.VERSION_20);

			assertion.getAuthnStatements().add(authnStatement);
			assertion.setConditions(conditions);
			assertion.setSubject(subject);
			return assertion;
		} catch (ConfigurationException e) {
			logger.error(e, e);
		}
		return null;
	}

}

TestSSO.java:

public class TestSSO {

	private PrivateKey privateKey;
	private X509Certificate certificate;

	public void readCertificate(InputStream inputStream, String alias, String password) throws NoSuchAlgorithmException, CertificateException, IOException, UnrecoverableKeyException, KeyStoreException {
		KeyStore keyStore = KeyStore.getInstance("JKS");
		keyStore.load(inputStream, password.toCharArray());

		Key key = keyStore.getKey(alias, password.toCharArray());
		if (key == null) {
			throw new RuntimeException("Got null key from keystore!");
		}

		privateKey = (PrivateKey) key;
		certificate = (X509Certificate) keyStore.getCertificate(alias);
		if (certificate == null) {
			throw new RuntimeException("Got null cert from keystore!");
		}
	}

	public void run() throws ConfigurationException, UnrecoverableKeyException, InvalidKeyException, NoSuchAlgorithmException, CertificateException, FileNotFoundException, KeyStoreException, NoSuchProviderException, SignatureException, IOException, org.opensaml.xml.signature.SignatureException, URISyntaxException, UnmarshallingException, MarshallingException {
		String strIssuer = "Eugene Burtsev";
		String strNameID = "user@test.com";

		InputStream inputStream = TestSSO.class.getResourceAsStream("/keystore");
		readCertificate(inputStream, "SSO", "12345678");

		SAMLResponseGenerator responseGenerator = new SalesforceSAMLResponseGenerator(certificate, certificate.getPublicKey(), privateKey, strIssuer, strNameID);
		String samlAssertion = responseGenerator.generateSAMLAssertionString();

		System.out.println();
		System.out.println("Assertion String: " + samlAssertion);
	}

	public static void main(String[] args) throws ConfigurationException, UnrecoverableKeyException, InvalidKeyException, NoSuchAlgorithmException, CertificateException, FileNotFoundException, KeyStoreException, NoSuchProviderException, SignatureException, IOException, org.opensaml.xml.signature.SignatureException, URISyntaxException, UnmarshallingException, MarshallingException {
		new TestSSO().run();
	}

}

Не смотря на все уверения валидатора в том что код валидный, заставить SalesForce авторизовывать с помощью SSO оказалось не тривиальной задачей. Были опробованы около десятка примеров с их вики и ни один не заработал в лучшем случае говоря что мол «invalid assertion»… Так прошло три дня… И тут мне на ум пришло прочитать спецификации OASIS, в коих было почерпнуто сакральное знание о том что SAML нужно отправлять в параметре «SAMLResponse» POST запроса… Уже не надеясь на успех сие знание было применено на практике, и чудо таки свершилось — сейлсфорс выдал ссылку с токеном для логина. Ниже приведу полный пример программы иллюстрирующей правильный подход для реализации SSO для SalesForce.

public class TestSSO {

	private static final Logger logger = Logger.getLogger(TestSSO.class);

	public static DefaultHttpClient getThreadSafeClient() {

		DefaultHttpClient client = new DefaultHttpClient();
		ClientConnectionManager mgr = client.getConnectionManager();
		HttpParams params = client.getParams();
		client = new DefaultHttpClient(new ThreadSafeClientConnManager(
				mgr.getSchemeRegistry()), params);
		return client;
	}

	private static HttpClient createHttpClient() {

		HttpClient httpclient = getThreadSafeClient();
		httpclient.getParams().setParameter(
				CoreProtocolPNames.PROTOCOL_VERSION,
				new ProtocolVersion("HTTP", 1, 1));

		return httpclient;
	}

	private static void sendSamlRequest(String samlAssertion) {
		HttpClient httpClient = createHttpClient();
		try {
			System.out.println(samlAssertion);
			HttpPost httpPost = new HttpPost("https://login.salesforce.com/");
			MultipartEntity entity = new MultipartEntity(HttpMultipartMode.STRICT);
			entity.addPart("SAMLResponse", new StringBody(samlAssertion));
			httpPost.setEntity(entity);
			HttpResponse httpResponse = httpClient.execute(httpPost);

			Header location = httpResponse.getFirstHeader("Location");
			if (null != location) {
				System.out.println(location.getValue());
			}
		    
		} catch (Exception e) {
			logger.error(e, e);
		} finally {
			httpClient.getConnectionManager().shutdown();
		}
	}

	private PrivateKey privateKey;
	private X509Certificate certificate;

	public void readCertificate(InputStream inputStream, String alias, String password) throws NoSuchAlgorithmException, CertificateException, IOException, UnrecoverableKeyException, KeyStoreException {
		KeyStore keyStore = KeyStore.getInstance("JKS");
		keyStore.load(inputStream, password.toCharArray());

		Key key = keyStore.getKey(alias, password.toCharArray());
		if (key == null) {
			throw new RuntimeException("Got null key from keystore!");
		}

		privateKey = (PrivateKey) key;
		certificate = (X509Certificate) keyStore.getCertificate(alias);
		if (certificate == null) {
			throw new RuntimeException("Got null cert from keystore!");
		}
	}

	public void run() throws ConfigurationException, UnrecoverableKeyException, InvalidKeyException, NoSuchAlgorithmException, CertificateException, FileNotFoundException, KeyStoreException, NoSuchProviderException, SignatureException, IOException, org.opensaml.xml.signature.SignatureException, URISyntaxException, UnmarshallingException, MarshallingException {
		String strIssuer = "Eugene Burtsev";
		String strNameID = "user@test.com";

		InputStream inputStream = TestSSO.class.getResourceAsStream("/keystore");
		readCertificate(inputStream, "SSO", "12345678");

		SAMLResponseGenerator responseGenerator = new SalesforceSAMLResponseGenerator(certificate, certificate.getPublicKey(), privateKey, strIssuer, strNameID);
		String samlAssertion = responseGenerator.generateSAMLAssertionString();

		System.out.println();
		System.out.println("Assertion String: " + samlAssertion);

		sendSamlRequest(Base64.encodeBase64String(samlAssertion.getBytes("UTF-8")));
	}

	public static void main(String[] args) throws ConfigurationException, UnrecoverableKeyException, InvalidKeyException, NoSuchAlgorithmException, CertificateException, FileNotFoundException, KeyStoreException, NoSuchProviderException, SignatureException, IOException, org.opensaml.xml.signature.SignatureException, URISyntaxException, UnmarshallingException, MarshallingException {
		new TestSSO().run();
	}

}

Архив с исходниками можно взять здесь
И напоследок мораль: Не верь никому, только спеки несут истину!

Список полезных ресурсов:

  1. Single Sign-On with SAML on Force.com
  2. docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
  3. www.sslshopper.com/article-most-common-java-keytool-keystore-commands.html

Автор: burtsev

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js