diff --git a/dependencies/server-all/pom.xml b/dependencies/server-all/pom.xml index 0f7fed876d..a812b08f12 100755 --- a/dependencies/server-all/pom.xml +++ b/dependencies/server-all/pom.xml @@ -138,6 +138,17 @@ ${project.version} + + + org.keycloak + keycloak-saml-protocol + ${project.version} + + + org.picketlink + picketlink-federation + + org.keycloak diff --git a/pom.xml b/pom.xml index 55f536905c..73523c09df 100755 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ 2.3.7.Final 3.0.9.Final 1.0.15.Final - 2.7.0.Beta1 + 2.7.0.CR1-20140924 1.0.2.Final 2.11.3 3.1.4.GA @@ -107,6 +107,7 @@ picketlink federation services + saml social forms examples @@ -229,6 +230,11 @@ picketlink-idm-impl ${picketlink.version} + + org.picketlink + picketlink-federation + ${picketlink.version} + org.picketlink picketlink-idm-simple-schema diff --git a/saml/pom.xml b/saml/pom.xml new file mode 100755 index 0000000000..4e7a81f5f5 --- /dev/null +++ b/saml/pom.xml @@ -0,0 +1,20 @@ + + + keycloak-parent + org.keycloak + 1.1.0-Alpha1-SNAPSHOT + ../pom.xml + + Keycloak SAML Integration + + 4.0.0 + + keycloak-saml-pom + pom + + + saml-core + saml-protocol + + diff --git a/saml/saml-core/pom.xml b/saml/saml-core/pom.xml new file mode 100755 index 0000000000..87b0250721 --- /dev/null +++ b/saml/saml-core/pom.xml @@ -0,0 +1,55 @@ + + + + keycloak-parent + org.keycloak + 1.1.0-Alpha1-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-saml-core + Keycloak SAML Core + + + + ${maven.build.timestamp} + yyyy-MM-dd HH:mm + + + + org.picketlink + picketlink-federation + + + org.jboss.resteasy + jaxrs-api + provided + + + junit + junit + test + + + + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + diff --git a/saml/saml-protocol/pom.xml b/saml/saml-protocol/pom.xml new file mode 100755 index 0000000000..ea64438253 --- /dev/null +++ b/saml/saml-protocol/pom.xml @@ -0,0 +1,128 @@ + + + + keycloak-parent + org.keycloak + 1.1.0-Alpha1-SNAPSHOT + ../../pom.xml + + 4.0.0 + + keycloak-saml-protocol + Keycloak SAML Protocol + + + + ${maven.build.timestamp} + yyyy-MM-dd HH:mm + + + + org.keycloak + keycloak-core + ${project.version} + provided + + + org.keycloak + keycloak-services + ${project.version} + provided + + + org.keycloak + keycloak-forms-common-freemarker + ${project.version} + provided + + + org.keycloak + keycloak-events-api + ${project.version} + provided + + + org.keycloak + keycloak-account-api + ${project.version} + provided + + + org.keycloak + keycloak-email-api + ${project.version} + provided + + + org.keycloak + keycloak-login-api + ${project.version} + provided + + + org.keycloak + keycloak-model-api + ${project.version} + provided + + + org.jboss.logging + jboss-logging + provided + + + org.jboss.resteasy + resteasy-jaxrs + provided + + + log4j + log4j + + + org.slf4j + slf4j-api + + + org.slf4j + slf4j-simple + + + + + org.picketlink + picketlink-federation + provided + + + org.jboss.resteasy + jaxrs-api + provided + + + junit + junit + test + + + + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + diff --git a/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SAML2PostBindingResponseBuilder.java b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SAML2PostBindingResponseBuilder.java new file mode 100755 index 0000000000..0d7d4f5df0 --- /dev/null +++ b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SAML2PostBindingResponseBuilder.java @@ -0,0 +1,320 @@ +package org.keycloak.protocol.saml; +/* + * JBoss, Home of Professional Open Source. + * Copyright 2008, Red Hat Middleware LLC, and individual contributors + * as indicated by the @author tags. See the copyright.txt file in the + * distribution for a full listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ + +import org.picketlink.common.PicketLinkLogger; +import org.picketlink.common.PicketLinkLoggerFactory; +import org.picketlink.common.constants.GeneralConstants; +import org.picketlink.common.constants.JBossSAMLURIConstants; +import org.picketlink.common.exceptions.ConfigurationException; +import org.picketlink.common.exceptions.ProcessingException; +import org.picketlink.common.util.DocumentUtil; +import org.picketlink.identity.federation.api.saml.v2.response.SAML2Response; +import org.picketlink.identity.federation.api.saml.v2.sig.SAML2Signature; +import org.picketlink.identity.federation.core.saml.v2.common.IDGenerator; +import org.picketlink.identity.federation.core.saml.v2.factories.JBossSAMLAuthnResponseFactory; +import org.picketlink.identity.federation.core.saml.v2.holders.IDPInfoHolder; +import org.picketlink.identity.federation.core.saml.v2.holders.IssuerInfoHolder; +import org.picketlink.identity.federation.core.saml.v2.holders.SPInfoHolder; +import org.picketlink.identity.federation.core.saml.v2.util.StatementUtil; +import org.picketlink.identity.federation.core.saml.v2.util.XMLTimeUtil; +import org.picketlink.identity.federation.saml.v2.assertion.AssertionType; +import org.picketlink.identity.federation.saml.v2.assertion.AttributeStatementType; +import org.picketlink.identity.federation.saml.v2.assertion.AuthnStatementType; +import org.picketlink.identity.federation.saml.v2.protocol.ResponseType; +import org.picketlink.identity.federation.web.util.PostBindingUtil; +import org.w3c.dom.Document; + +import javax.ws.rs.core.CacheControl; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.picketlink.common.util.StringUtil.isNotNull; + +/** + *

Handles for dealing with SAML2 Authentication

+ *

+ * Configuration Options: + * + * @author Anil.Saldhana@redhat.com +*/ +public class SAML2PostBindingResponseBuilder { + protected static final PicketLinkLogger logger = PicketLinkLoggerFactory.getLogger(); + + protected List roles = new LinkedList(); + protected String userPrincipal; + protected boolean multiValuedRoles; + protected boolean disableAuthnStatement; + protected String requestID; + protected String responseIssuer; + protected String authMethod; + protected String relayState; + protected String destination; + protected String requestIssuer; + protected Map attributes = new HashMap(); + + + public SAML2PostBindingResponseBuilder attributes(Map attributes) { + this.attributes = attributes; + return this; + } + + public SAML2PostBindingResponseBuilder attribute(String name, Object value) { + this.attributes.put(name, value); + return this; + } + + public SAML2PostBindingResponseBuilder requestID(String requestID) { + this.requestID =requestID; + return this; + } + + public SAML2PostBindingResponseBuilder requestIssuer(String requestIssuer) { + this.requestIssuer =requestIssuer; + return this; + } + + public SAML2PostBindingResponseBuilder responseIssuer(String issuer) { + this.responseIssuer = issuer; + return this; + } + + public SAML2PostBindingResponseBuilder roles(List roles) { + this.roles = roles; + return this; + } + + public SAML2PostBindingResponseBuilder roles(String... roles) { + for (String role : roles) { + this.roles.add(role); + } + return this; + } + + public SAML2PostBindingResponseBuilder authMethod(String authMethod) { + this.authMethod = authMethod; + return this; + } + + public SAML2PostBindingResponseBuilder userPrincipal(String userPrincipal) { + this.userPrincipal = userPrincipal; + return this; + } + + public SAML2PostBindingResponseBuilder relayState(String relayState) { + this.relayState = relayState; + return this; + } + + public SAML2PostBindingResponseBuilder destination(String destination) { + this.destination = destination; + return this; + } + + public SAML2PostBindingResponseBuilder multiValuedRoles(boolean multiValuedRoles) { + this.multiValuedRoles = multiValuedRoles; + return this; + } + + public SAML2PostBindingResponseBuilder disableAuthnStatement(boolean disableAuthnStatement) { + this.disableAuthnStatement = disableAuthnStatement; + return this; + } + + public Response error(String status) throws ConfigurationException, ProcessingException, IOException { + Document doc = getErrorResponse(status); + return buildResponse(doc); + + + } + + public Document getErrorResponse(String status) { + Document samlResponse = null; + ResponseType responseType = null; + + SAML2Response saml2Response = new SAML2Response(); + + // Create a response type + String id = IDGenerator.create("ID_"); + + IssuerInfoHolder issuerHolder = new IssuerInfoHolder(responseIssuer); + issuerHolder.setStatusCode(status); + + IDPInfoHolder idp = new IDPInfoHolder(); + idp.setNameIDFormatValue(null); + idp.setNameIDFormat(JBossSAMLURIConstants.NAMEID_FORMAT_PERSISTENT.get()); + + SPInfoHolder sp = new SPInfoHolder(); + sp.setResponseDestinationURI(destination); + + responseType = saml2Response.createResponseType(id); + responseType.setStatus(JBossSAMLAuthnResponseFactory.createStatusTypeForResponder(status)); + responseType.setDestination(destination); + + // Lets see how the response looks like + if (logger.isTraceEnabled()) { + StringWriter sw = new StringWriter(); + try { + saml2Response.marshall(responseType, sw); + } catch (ProcessingException e) { + logger.trace(e); + } + logger.trace("SAML Response Document: " + sw.toString()); + } + + /* + if (supportSignature) { + try { + SAML2Signature ss = new SAML2Signature(); + samlResponse = ss.sign(responseType, keyManager.getSigningKeyPair()); + } catch (Exception e) { + logger.trace(e); + throw new RuntimeException(logger.signatureError(e)); + } + } else + try { + samlResponse = saml2Response.convert(responseType); + } catch (Exception e) { + logger.trace(e); + } + */ + + return samlResponse; + } + + public Response build() throws ConfigurationException, ProcessingException, IOException { + Document responseDoc = getResponse(); + return buildResponse(responseDoc); + } + + protected Response buildResponse(Document responseDoc) throws ProcessingException, ConfigurationException, IOException { + byte[] responseBytes = DocumentUtil.getDocumentAsString(responseDoc).getBytes("UTF-8"); + String samlResponse = PostBindingUtil.base64Encode(new String(responseBytes)); + + if (destination == null) { + throw logger.nullValueError("Destination is null"); + } + + StringBuilder builder = new StringBuilder(); + + String key = GeneralConstants.SAML_RESPONSE_KEY; + builder.append(""); + builder.append(""); + + builder.append("HTTP Post Binding Response (Response)"); + builder.append(""); + builder.append(""); + + builder.append("

"); + builder.append(""); + + if (isNotNull(relayState)) { + builder.append(""); + } + + builder.append(""); + + builder.append("
"); + + String str = builder.toString(); + + CacheControl cacheControl = new CacheControl(); + cacheControl.setNoCache(true); + return Response.ok(str, MediaType.TEXT_HTML_TYPE) + .header("Pragma", "no-cache") + .header("Cache-Control", "no-cache, no-store").build(); + } + + public Document getResponse() throws ConfigurationException, ProcessingException { + + Document samlResponseDocument = null; + + ResponseType responseType = null; + + SAML2Response saml2Response = new SAML2Response(); + + // Create a response type + String id = IDGenerator.create("ID_"); + + IssuerInfoHolder issuerHolder = new IssuerInfoHolder(responseIssuer); + issuerHolder.setStatusCode(JBossSAMLURIConstants.STATUS_SUCCESS.get()); + + IDPInfoHolder idp = new IDPInfoHolder(); + idp.setNameIDFormatValue(userPrincipal); + idp.setNameIDFormat(JBossSAMLURIConstants.NAMEID_FORMAT_PERSISTENT.get()); + + SPInfoHolder sp = new SPInfoHolder(); + sp.setResponseDestinationURI(destination); + sp.setRequestID(requestID); + sp.setIssuer(requestIssuer); + responseType = saml2Response.createResponseType(id, sp, idp, issuerHolder); + + // Add information on the roles + AssertionType assertion = responseType.getAssertions().get(0).getAssertion(); + + // Create an AuthnStatementType + if (!disableAuthnStatement) { + String authContextRef = JBossSAMLURIConstants.AC_PASSWORD.get(); + if (isNotNull(authMethod)) + authContextRef = authMethod; + + AuthnStatementType authnStatement = StatementUtil.createAuthnStatement(XMLTimeUtil.getIssueInstant(), + authContextRef); + + authnStatement.setSessionIndex(assertion.getID()); + + assertion.addStatement(authnStatement); + } + + if (roles != null && !roles.isEmpty()) { + AttributeStatementType attrStatement = StatementUtil.createAttributeStatementForRoles(roles, multiValuedRoles); + assertion.addStatement(attrStatement); + } + + // Add in the attributes information + if (attributes != null && attributes.size() > 0) { + AttributeStatementType attStatement = StatementUtil.createAttributeStatement(attributes); + assertion.addStatement(attStatement); + } + + try { + samlResponseDocument = saml2Response.convert(responseType); + + if (logger.isTraceEnabled()) { + logger.trace("SAML Response Document: " + DocumentUtil.asString(samlResponseDocument)); + } + } catch (Exception e) { + throw logger.samlAssertionMarshallError(e); + } + + return samlResponseDocument; + } +} diff --git a/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SAMLRequestParser.java b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SAMLRequestParser.java new file mode 100755 index 0000000000..4d5b6d4b24 --- /dev/null +++ b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SAMLRequestParser.java @@ -0,0 +1,48 @@ +package org.keycloak.protocol.saml; + +import org.picketlink.common.PicketLinkLogger; +import org.picketlink.common.PicketLinkLoggerFactory; +import org.picketlink.identity.federation.api.saml.v2.request.SAML2Request; +import org.picketlink.identity.federation.core.saml.v2.common.SAMLDocumentHolder; +import org.picketlink.identity.federation.web.util.PostBindingUtil; +import org.picketlink.identity.federation.web.util.RedirectBindingUtil; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SAMLRequestParser { + private static final PicketLinkLogger logger = PicketLinkLoggerFactory.getLogger(); + + public static SAMLDocumentHolder parseRedirectBinding(String samlMessage) { + InputStream is; + is = RedirectBindingUtil.base64DeflateDecode(samlMessage); + SAML2Request saml2Request = new SAML2Request(); + try { + saml2Request.getSAML2ObjectFromStream(is); + return saml2Request.getSamlDocumentHolder(); + } catch (Exception e) { + logger.samlBase64DecodingError(e); + } + return null; + + } + + public static SAMLDocumentHolder parsePostBinding(String samlMessage) { + InputStream is; + byte[] samlBytes = PostBindingUtil.base64Decode(samlMessage); + is = new ByteArrayInputStream(samlBytes); + SAML2Request saml2Request = new SAML2Request(); + try { + saml2Request.getSAML2ObjectFromStream(is); + return saml2Request.getSamlDocumentHolder(); + } catch (Exception e) { + logger.samlBase64DecodingError(e); + } + return null; + + } +} diff --git a/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlLogin.java b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlLogin.java new file mode 100755 index 0000000000..8344170e5d --- /dev/null +++ b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlLogin.java @@ -0,0 +1,170 @@ +package org.keycloak.protocol.saml; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.ClientConnection; +import org.keycloak.models.ClaimMask; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.services.managers.ClientSessionCode; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.services.resources.flows.Flows; +import org.picketlink.common.constants.GeneralConstants; +import org.picketlink.common.constants.JBossSAMLURIConstants; +import org.picketlink.common.exceptions.ConfigurationException; +import org.picketlink.common.exceptions.ProcessingException; +import org.picketlink.identity.federation.core.saml.v2.constants.X500SAMLProfileConstants; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.io.IOException; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SamlLogin implements LoginProtocol { + protected static final Logger logger = Logger.getLogger(SamlLogin.class); + public static final String LOGIN_PROTOCOL = "saml"; + public static final String SAML_BINDING = "saml_binding"; + public static final String SAML_POST_BINDING = "post"; + + protected KeycloakSession session; + + protected RealmModel realm; + + protected HttpRequest request; + + protected UriInfo uriInfo; + + protected ClientConnection clientConnection; + + + @Override + public SamlLogin setSession(KeycloakSession session) { + this.session = session; + return this; + } + + @Override + public SamlLogin setRealm(RealmModel realm) { + this.realm = realm; + return this; + } + + @Override + public SamlLogin setRequest(HttpRequest request) { + this.request = request; + return this; + } + + @Override + public SamlLogin setUriInfo(UriInfo uriInfo) { + this.uriInfo = uriInfo; + return this; + } + + @Override + public SamlLogin setClientConnection(ClientConnection clientConnection) { + this.clientConnection = clientConnection; + return this; + } + + @Override + public Response cancelLogin(ClientSessionModel clientSession) { + return getErrorResponse(clientSession, JBossSAMLURIConstants.STATUS_REQUEST_DENIED.get()); + } + + @Override + public Response invalidSessionError(ClientSessionModel clientSession) { + return getErrorResponse(clientSession, JBossSAMLURIConstants.STATUS_AUTHNFAILED.get()); + } + + protected String getResponseIssuer(RealmModel realm) { + return RealmsResource.realmBaseUrl(uriInfo).build(realm.getName()).toString(); + } + + protected Response getErrorResponse(ClientSessionModel clientSession, String status) { + String relayState = clientSession.getNote(GeneralConstants.RELAY_STATE); + String redirectUri = clientSession.getRedirectUri(); + SAML2PostBindingResponseBuilder builder = new SAML2PostBindingResponseBuilder(); + String responseIssuer = getResponseIssuer(realm); + builder .relayState(relayState) + .destination(redirectUri) + .responseIssuer(responseIssuer) + .requestIssuer(clientSession.getClient().getClientId()); + try { + return builder.error(status); + } catch (Exception e) { + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Failed to process response"); + } + } + + @Override + public Response authenticated(UserSessionModel userSession, ClientSessionCode accessCode) { + ClientSessionModel clientSession = accessCode.getClientSession(); + if (SamlLogin.SAML_POST_BINDING.equals(clientSession.getNote(SamlLogin.SAML_BINDING))) { + return postBinding(userSession, clientSession); + } + throw new RuntimeException("still need to implement redirect binding"); + } + + protected Response postBinding(UserSessionModel userSession, ClientSessionModel clientSession) { + String requestID = clientSession.getNote("REQUEST_ID"); + String relayState = clientSession.getNote(GeneralConstants.RELAY_STATE); + String redirectUri = clientSession.getRedirectUri(); + String responseIssuer = getResponseIssuer(realm); + + SAML2PostBindingResponseBuilder builder = new SAML2PostBindingResponseBuilder(); + builder.requestID(requestID) + .relayState(relayState) + .destination(redirectUri) + .responseIssuer(responseIssuer) + .requestIssuer(clientSession.getClient().getClientId()) + .userPrincipal(userSession.getUser().getUsername()) // todo userId instead? There is no username claim it seems + .attribute(X500SAMLProfileConstants.USERID.getFriendlyName(), userSession.getUser().getId()) + .authMethod(JBossSAMLURIConstants.AC_UNSPECIFIED.get()); + initClaims(builder, clientSession.getClient(), userSession.getUser()); + if (clientSession.getRoles() != null) { + for (String roleId : clientSession.getRoles()) { + // todo need a role mapping + RoleModel roleModel = clientSession.getRealm().getRoleById(roleId); + builder.roles(roleModel.getName()); + } + } + + try { + return builder.build(); + } catch (Exception e) { + logger.error("failed", e); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Failed to process response"); + } + } + + public void initClaims(SAML2PostBindingResponseBuilder builder, ClientModel model, UserModel user) { + if (ClaimMask.hasEmail(model.getAllowedClaimsMask())) { + builder.attribute(X500SAMLProfileConstants.EMAIL_ADDRESS.getFriendlyName(), user.getEmail()); + } + if (ClaimMask.hasName(model.getAllowedClaimsMask())) { + builder.attribute(X500SAMLProfileConstants.GIVEN_NAME.getFriendlyName(), user.getFirstName()); + builder.attribute(X500SAMLProfileConstants.SURNAME.getFriendlyName(), user.getLastName()); + } + } + + + @Override + public Response consentDenied(ClientSessionModel clientSession) { + return getErrorResponse(clientSession, JBossSAMLURIConstants.STATUS_REQUEST_DENIED.get()); + } + + @Override + public void close() { + + } +} diff --git a/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlLoginFactory.java b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlLoginFactory.java new file mode 100755 index 0000000000..21618ed733 --- /dev/null +++ b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlLoginFactory.java @@ -0,0 +1,43 @@ +package org.keycloak.protocol.saml; + +import org.keycloak.Config; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.LoginProtocolFactory; +import org.keycloak.services.managers.AuthenticationManager; +import org.picketlink.identity.federation.core.sts.PicketLinkCoreSTS; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SamlLoginFactory implements LoginProtocolFactory { + + @Override + public Object createProtocolEndpoint(RealmModel realm, EventBuilder event, AuthenticationManager authManager) { + return new SamlService(realm, event, authManager); + } + + @Override + public LoginProtocol create(KeycloakSession session) { + return new SamlLogin().setSession(session); + } + + @Override + public void init(Config.Scope config) { + PicketLinkCoreSTS sts = PicketLinkCoreSTS.instance(); + sts.installDefaultConfiguration(); + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "saml"; + } +} diff --git a/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlService.java b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlService.java new file mode 100755 index 0000000000..a5ed37d4e2 --- /dev/null +++ b/saml/saml-protocol/src/main/java/org/keycloak/protocol/saml/SamlService.java @@ -0,0 +1,246 @@ +package org.keycloak.protocol.saml; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.annotations.cache.NoCache; +import org.jboss.resteasy.specimpl.MultivaluedMapImpl; +import org.jboss.resteasy.spi.HttpRequest; +import org.jboss.resteasy.spi.HttpResponse; +import org.keycloak.ClientConnection; +import org.keycloak.OAuth2Constants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.login.LoginFormsProvider; +import org.keycloak.models.ApplicationModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.OpenIDConnectService; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.ClientSessionCode; +import org.keycloak.services.resources.flows.Flows; +import org.picketlink.common.constants.GeneralConstants; +import org.picketlink.identity.federation.core.saml.v2.common.SAMLDocumentHolder; +import org.picketlink.identity.federation.saml.v2.SAML2Object; +import org.picketlink.identity.federation.saml.v2.protocol.AuthnRequestType; + +import javax.ws.rs.Consumes; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Cookie; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriBuilder; +import javax.ws.rs.core.UriInfo; +import javax.ws.rs.ext.Providers; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +/** + * Resource class for the oauth/openid connect token service + * + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class SamlService { + + protected static final Logger logger = Logger.getLogger(SamlService.class); + + protected RealmModel realm; + private EventBuilder event; + protected AuthenticationManager authManager; + + @Context + protected Providers providers; + @Context + protected SecurityContext securityContext; + @Context + protected UriInfo uriInfo; + @Context + protected HttpHeaders headers; + @Context + protected HttpRequest request; + @Context + protected HttpResponse response; + @Context + protected KeycloakSession session; + @Context + protected ClientConnection clientConnection; + + /* + @Context + protected ResourceContext resourceContext; + */ + + public SamlService(RealmModel realm, EventBuilder event, AuthenticationManager authManager) { + this.realm = realm; + this.event = event; + this.authManager = authManager; + } + + /** + */ + @Path("POST") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + public Response loginPage(@FormParam(GeneralConstants.SAML_REQUEST_KEY) String samlRequest, + @FormParam(GeneralConstants.RELAY_STATE) String relayState) { + event.event(EventType.LOGIN); + if (!checkSsl()) { + event.error(Errors.SSL_REQUIRED); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "HTTPS required"); + } + if (!realm.isEnabled()) { + event.error(Errors.REALM_DISABLED); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Realm not enabled"); + } + + if (samlRequest == null) { + event.error(Errors.INVALID_TOKEN); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Invalid Request"); + + } + + SAMLDocumentHolder documentHolder = SAMLRequestParser.parsePostBinding(samlRequest); + if (documentHolder == null) { + event.error(Errors.INVALID_TOKEN); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Invalid Request"); + } + + SAML2Object samlObject = documentHolder.getSamlObject(); + if (!(samlObject instanceof AuthnRequestType)) { + event.error(Errors.INVALID_TOKEN); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Invalid Request"); + } + + // Get the SAML Request Message + AuthnRequestType requestAbstractType = (AuthnRequestType) samlObject; + String issuer = requestAbstractType.getIssuer().getValue(); + ClientModel client = realm.findClient(issuer); + + if (client == null) { + event.error(Errors.CLIENT_NOT_FOUND); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Unknown login requester."); + } + + if (!client.isEnabled()) { + event.error(Errors.CLIENT_DISABLED); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Login requester not enabled."); + } + if ((client instanceof ApplicationModel) && ((ApplicationModel)client).isBearerOnly()) { + event.error(Errors.NOT_ALLOWED); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Bearer-only applications are not allowed to initiate browser login"); + } + if (client.isDirectGrantsOnly()) { + event.error(Errors.NOT_ALLOWED); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "direct-grants-only clients are not allowed to initiate browser login"); + } + + URI redirectUri = requestAbstractType.getAssertionConsumerServiceURL(); + String redirect = OpenIDConnectService.verifyRedirectUri(uriInfo, redirectUri.toString(), realm, client); + + if (redirect == null) { + event.error(Errors.INVALID_REDIRECT_URI); + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Invalid redirect_uri."); + } + + + ClientSessionModel clientSession = session.sessions().createClientSession(realm, client); + clientSession.setAuthMethod(SamlLogin.LOGIN_PROTOCOL); + clientSession.setRedirectUri(redirect); + clientSession.setAction(ClientSessionModel.Action.AUTHENTICATE); + clientSession.setNote(SamlLogin.SAML_BINDING, SamlLogin.SAML_POST_BINDING); + clientSession.setNote(GeneralConstants.RELAY_STATE, relayState); + clientSession.setNote("REQUEST_ID", requestAbstractType.getID()); + + Response response = authManager.checkNonFormAuthentication(session, clientSession, realm, uriInfo, request, clientConnection, headers, event); + if (response != null) return response; + + LoginFormsProvider forms = Flows.forms(session, realm, clientSession.getClient(), uriInfo) + .setClientSessionCode(new ClientSessionCode(realm, clientSession).getCode()); + + String rememberMeUsername = null; + if (realm.isRememberMe()) { + Cookie rememberMeCookie = headers.getCookies().get(AuthenticationManager.KEYCLOAK_REMEMBER_ME); + if (rememberMeCookie != null && !"".equals(rememberMeCookie.getValue())) { + rememberMeUsername = rememberMeCookie.getValue(); + } + } + + if (rememberMeUsername != null) { + MultivaluedMap formData = new MultivaluedMapImpl(); + formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername); + formData.add("rememberMe", "on"); + + forms.setFormData(formData); + } + + return forms.createLogin(); + } + + + /** + * Logout user session. User must be logged in via a session cookie. + * + * @param redirectUri + * @return + */ + @Path("logout") + @GET + @NoCache + public Response logout(final @QueryParam("shit") String redirectUri) { + event.event(EventType.LOGOUT); + if (redirectUri != null) { + event.detail(Details.REDIRECT_URI, redirectUri); + } + // authenticate identity cookie, but ignore an access token timeout as we're logging out anyways. + AuthenticationManager.AuthResult authResult = authManager.authenticateIdentityCookie(session, realm, uriInfo, clientConnection, headers, false); + if (authResult != null) { + logout(authResult.getSession()); + } + + if (redirectUri != null) { + String validatedRedirect = OpenIDConnectService.verifyRealmRedirectUri(uriInfo, redirectUri, realm); + if (validatedRedirect == null) { + return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, "Invalid redirect uri."); + } + return Response.status(302).location(UriBuilder.fromUri(validatedRedirect).build()).build(); + } else { + return Response.ok().build(); + } + } + + private void logout(UserSessionModel userSession) { + authManager.logout(session, realm, userSession, uriInfo, clientConnection); + event.user(userSession.getUser()).session(userSession).success(); + } + + private boolean checkSsl() { + if (uriInfo.getBaseUri().getScheme().equals("https")) { + return true; + } else { + return !realm.getSslRequired().isRequired(clientConnection); + } + } + + private Response createError(String error, String errorDescription, Response.Status status) { + Map e = new HashMap(); + e.put(OAuth2Constants.ERROR, error); + if (errorDescription != null) { + e.put(OAuth2Constants.ERROR_DESCRIPTION, errorDescription); + } + return Response.status(status).entity(e).type("application/json").build(); + } + +} diff --git a/saml/saml-protocol/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory b/saml/saml-protocol/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory new file mode 100755 index 0000000000..632a1dbf02 --- /dev/null +++ b/saml/saml-protocol/src/main/resources/META-INF/services/org.keycloak.protocol.LoginProtocolFactory @@ -0,0 +1 @@ +org.keycloak.protocol.saml.SamlLoginFactory \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/LoginProtocol.java b/services/src/main/java/org/keycloak/protocol/LoginProtocol.java index 8a66f166cb..86de30a657 100755 --- a/services/src/main/java/org/keycloak/protocol/LoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/LoginProtocol.java @@ -18,15 +18,15 @@ import javax.ws.rs.core.UriInfo; * @version $Revision: 1 $ */ public interface LoginProtocol extends Provider { - OpenIDConnect setSession(KeycloakSession session); + LoginProtocol setSession(KeycloakSession session); - OpenIDConnect setRealm(RealmModel realm); + LoginProtocol setRealm(RealmModel realm); - OpenIDConnect setRequest(HttpRequest request); + LoginProtocol setRequest(HttpRequest request); - OpenIDConnect setUriInfo(UriInfo uriInfo); + LoginProtocol setUriInfo(UriInfo uriInfo); - OpenIDConnect setClientConnection(ClientConnection clientConnection); + LoginProtocol setClientConnection(ClientConnection clientConnection); Response cancelLogin(ClientSessionModel clientSession); Response invalidSessionError(ClientSessionModel clientSession); diff --git a/testsuite/integration/src/test/resources/testsaml.json b/testsuite/integration/src/test/resources/testsaml.json new file mode 100755 index 0000000000..6dc9d71c33 --- /dev/null +++ b/testsuite/integration/src/test/resources/testsaml.json @@ -0,0 +1,48 @@ +{ + "id": "demo", + "realm": "demo", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "resetPasswordAllowed": true, + "passwordCredentialGrantAllowed": true, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": [ "password" ], + "defaultRoles": [ "user" ], + "smtpServer": { + "from": "auto@keycloak.org", + "host": "localhost", + "port":"3025" + }, + "users" : [ + { + "username" : "bburke", + "enabled": true, + "email" : "bburke@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["manager"] + } + ], + "applications": [ + { + "name": "http://localhost:8080/sales-post/", + "enabled": true, + "fullScopeAllowed": true, + "redirectUris": [ + "http://localhost:8080/sales-post/*" + ] + } + ], + "roles" : { + "realm" : [ + { + "name": "manager", + "description": "Have Manager privileges" + } + ] + } +}