Commit 74023ad3 authored by Erlend Hamnaberg's avatar Erlend Hamnaberg Committed by Doccrazy

Saml 1.1 Validate support

The reasoning for this commit is that this is well supported by CAS,
and it might be reasonable to include this in something that emulates
CAS.

Use Saml lib provided by keycloak
parent cbb2f2f8
......@@ -61,6 +61,7 @@
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<version>${jboss.logging.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
......@@ -85,7 +86,7 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-saml-core</artifactId>
<version>${keycloak.version}</version>
<scope>test</scope>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
......@@ -111,7 +112,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<version>3.8.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
......@@ -126,7 +127,7 @@
<configuration>
<archive>
<manifestEntries>
<Dependencies>javax.xml.bind.api,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services</Dependencies>
<Dependencies>javax.xml.bind.api,org.keycloak.keycloak-core,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services,org.keycloak.keycloak-saml-core,org.keycloak.keycloak-saml-core-public</Dependencies>
</manifestEntries>
</archive>
</configuration>
......
......@@ -25,6 +25,7 @@ public class CASLoginProtocol implements LoginProtocol {
public static final String LOGIN_PROTOCOL = "cas";
public static final String SERVICE_PARAM = "service";
public static final String TARGET_PARAM = "TARGET";
public static final String RENEW_PARAM = "renew";
public static final String GATEWAY_PARAM = "gateway";
public static final String TICKET_PARAM = "ticket";
......
......@@ -5,10 +5,7 @@ import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.cas.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.cas.endpoints.LogoutEndpoint;
import org.keycloak.protocol.cas.endpoints.ServiceValidateEndpoint;
import org.keycloak.protocol.cas.endpoints.ValidateEndpoint;
import org.keycloak.protocol.cas.endpoints.*;
import org.keycloak.services.resources.RealmsResource;
import javax.ws.rs.Path;
......@@ -57,6 +54,13 @@ public class CASLoginProtocolService {
return endpoint;
}
@Path("samlValidate")
public Object validateSaml11() {
SamlValidateEndpoint endpoint = new SamlValidateEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
return endpoint;
}
@Path("serviceValidate")
public Object serviceValidate() {
ServiceValidateEndpoint endpoint = new ServiceValidateEndpoint(realm, event);
......
package org.keycloak.protocol.cas.endpoints;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.*;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.mappers.CASAttributeMapper;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.util.DefaultClientSessionContext;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public abstract class AbstractValidateEndpoint {
protected final Logger logger = Logger.getLogger(getClass());
@Context
protected KeycloakSession session;
@Context
protected ClientConnection clientConnection;
@Context
protected HttpRequest request;
@Context
protected HttpHeaders headers;
protected RealmModel realm;
protected EventBuilder event;
protected ClientModel client;
protected AuthenticatedClientSessionModel clientSession;
public AbstractValidateEndpoint(RealmModel realm, EventBuilder event) {
this.realm = realm;
this.event = event;
}
protected void checkSsl() {
if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) {
throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN);
}
}
protected void checkRealm() {
if (!realm.isEnabled()) {
throw new CASValidationException(CASErrorCode.INTERNAL_ERROR, "Realm not enabled", Response.Status.FORBIDDEN);
}
}
protected void checkClient(String service) {
if (service == null) {
event.error(Errors.INVALID_REQUEST);
throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.SERVICE_PARAM, Response.Status.BAD_REQUEST);
}
client = realm.getClients().stream()
.filter(c -> CASLoginProtocol.LOGIN_PROTOCOL.equals(c.getProtocol()))
.filter(c -> RedirectUtils.verifyRedirectUri(session.getContext().getUri(), service, realm, c) != null)
.findFirst().orElse(null);
if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client not found", Response.Status.BAD_REQUEST);
}
if (!client.isEnabled()) {
event.error(Errors.CLIENT_DISABLED);
throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client disabled", Response.Status.BAD_REQUEST);
}
event.client(client.getClientId());
session.getContext().setClient(client);
}
protected void checkTicket(String ticket, boolean requireReauth) {
if (ticket == null) {
event.error(Errors.INVALID_CODE);
throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.TICKET_PARAM, Response.Status.BAD_REQUEST);
}
if (!ticket.startsWith(CASLoginProtocol.SERVICE_TICKET_PREFIX)) {
event.error(Errors.INVALID_CODE);
throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Malformed service ticket", Response.Status.BAD_REQUEST);
}
String code = ticket.substring(CASLoginProtocol.SERVICE_TICKET_PREFIX.length());
String[] parts = code.split("\\.");
if (parts.length == 4) {
event.detail(Details.CODE_ID, parts[2]);
}
ClientSessionCode.ParseResult<AuthenticatedClientSessionModel> parseResult = ClientSessionCode.parseResult(code, null, session, realm, client, event, AuthenticatedClientSessionModel.class);
if (parseResult.isAuthSessionNotFound() || parseResult.isIllegalHash()) {
event.error(Errors.INVALID_CODE);
// Attempt to use same code twice should invalidate existing clientSession
AuthenticatedClientSessionModel clientSession = parseResult.getClientSession();
if (clientSession != null) {
clientSession.detachFromUserSession();
}
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
}
clientSession = parseResult.getClientSession();
if (parseResult.isExpiredToken()) {
event.error(Errors.EXPIRED_CODE);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST);
}
clientSession.setNote(CASLoginProtocol.SESSION_SERVICE_TICKET, ticket);
if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) {
event.error(Errors.SESSION_EXPIRED);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Interactive authentication was requested but not performed", Response.Status.BAD_REQUEST);
}
UserSessionModel userSession = clientSession.getUserSession();
if (userSession == null) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User session not found", Response.Status.BAD_REQUEST);
}
UserModel user = userSession.getUser();
if (user == null) {
event.error(Errors.USER_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User not found", Response.Status.BAD_REQUEST);
}
if (!user.isEnabled()) {
event.error(Errors.USER_DISABLED);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User disabled", Response.Status.BAD_REQUEST);
}
event.user(userSession.getUser());
event.session(userSession.getId());
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
event.error(Errors.INVALID_CODE);
throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Auth error", Response.Status.BAD_REQUEST);
}
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Session not active", Response.Status.BAD_REQUEST);
}
}
protected Map<String, Object> getUserAttributes() {
UserSessionModel userSession = clientSession.getUserSession();
// CAS protocol does not support scopes, so pass null scopeParam
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, null);
Set<ProtocolMapperModel> mappings = clientSessionCtx.getProtocolMappers();
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
Map<String, Object> attributes = new HashMap<>();
for (ProtocolMapperModel mapping : mappings) {
ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapping.getProtocolMapper());
if (mapper instanceof CASAttributeMapper) {
((CASAttributeMapper) mapper).setAttribute(attributes, mapping, userSession, session, clientSessionCtx);
}
}
return attributes;
}
}
package org.keycloak.protocol.cas.endpoints;
import org.keycloak.dom.saml.v1.protocol.SAML11ResponseType;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.representations.SamlResponseHelper;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.services.Urls;
import org.xml.sax.InputSource;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.xml.namespace.NamespaceContext;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import java.io.StringReader;
import java.util.*;
import static org.keycloak.protocol.cas.CASLoginProtocol.TARGET_PARAM;
public class SamlValidateEndpoint extends AbstractValidateEndpoint {
public SamlValidateEndpoint(RealmModel realm, EventBuilder event) {
super(realm, event.event(EventType.CODE_TO_TOKEN));
}
@POST
@Consumes("text/xml;charset=utf-8")
@Produces("text/xml;charset=utf-8")
public Response validate(String input) {
MultivaluedMap<String, String> queryParams = request.getUri().getQueryParameters();
try {
String soapAction = Optional.ofNullable(request.getHttpHeaders().getHeaderString("SOAPAction")).map(s -> s.trim().replace("\"", "")).orElse("");
if (!soapAction.equals("http://www.oasis-open.org/committees/security")) {
throw new CASValidationException(CASErrorCode.INTERNAL_ERROR, "Not a validation request", Response.Status.BAD_REQUEST);
}
String service = queryParams.getFirst(TARGET_PARAM);
boolean renew = queryParams.containsKey(CASLoginProtocol.RENEW_PARAM);
checkRealm();
checkSsl();
checkClient(service);
String issuer = Urls.realmIssuer(request.getUri().getBaseUri(), realm.getName());
String ticket = getTicket(input);
checkTicket(ticket, renew);
UserModel user = clientSession.getUserSession().getUser();
Map<String, Object> attributes = getUserAttributes();
SAML11ResponseType response = SamlResponseHelper.successResponse(issuer, user.getUsername(), attributes);
return Response.ok(SamlResponseHelper.soap(response)).build();
} catch (CASValidationException ex) {
logger.warnf("Invalid SAML1.1 token %s", ex.getErrorDescription());
SAML11ResponseType response = SamlResponseHelper.errorResponse(ex);
return Response.ok().entity(SamlResponseHelper.soap(response)).build();
}
}
private String getTicket(String input) {
try {
XPath xPath = XPathFactory.newInstance().newXPath();
xPath.setNamespaceContext(new MapNamespaceContext(Collections.singletonMap("samlp", "urn:oasis:names:tc:SAML:1.0:protocol")));
XPathExpression expression = xPath.compile("//samlp:AssertionArtifact/text()");
return expression.evaluate(new InputSource(new StringReader(input)));
} catch (XPathExpressionException ex) {
throw new CASValidationException(CASErrorCode.INVALID_TICKET, ex.getMessage(), Response.Status.BAD_REQUEST);
}
}
private static class MapNamespaceContext implements NamespaceContext {
Map<String, String> map;
private MapNamespaceContext(Map<String, String> map) {
this.map = map;
}
@Override
public String getNamespaceURI(String s) {
return map.get(s);
}
@Override
public String getPrefix(String s) {
return map.entrySet().stream().filter(e -> e.getValue().equals(s)).findFirst().map(Map.Entry::getKey).orElse(null);
}
@Override
public Iterator<String> getPrefixes(String s) {
return map.keySet().iterator();
}
}
}
......@@ -27,19 +27,7 @@ public class ServiceValidateEndpoint extends ValidateEndpoint {
@Override
protected Response successResponse() {
UserSessionModel userSession = clientSession.getUserSession();
// CAS protocol does not support scopes, so pass null scopeParam
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, null);
Set<ProtocolMapperModel> mappings = clientSessionCtx.getProtocolMappers();
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
Map<String, Object> attributes = new HashMap<>();
for (ProtocolMapperModel mapping : mappings) {
ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapping.getProtocolMapper());
if (mapper instanceof CASAttributeMapper) {
((CASAttributeMapper) mapper).setAttribute(attributes, mapping, userSession, session, clientSessionCtx);
}
}
Map<String, Object> attributes = getUserAttributes();
CASServiceResponse serviceResponse = ServiceResponseHelper.createSuccess(userSession.getUser().getUsername(), attributes);
return prepare(Response.Status.OK, serviceResponse);
}
......
package org.keycloak.protocol.cas.endpoints;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.*;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.cas.CASLoginProtocol;
import org.keycloak.protocol.cas.representations.CASErrorCode;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import javax.ws.rs.GET;
import javax.ws.rs.core.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
public class ValidateEndpoint {
protected static final Logger logger = Logger.getLogger(ValidateEndpoint.class);
public class ValidateEndpoint extends AbstractValidateEndpoint {
private static final String RESPONSE_OK = "yes\n";
private static final String RESPONSE_FAILED = "no\n";
@Context
protected KeycloakSession session;
@Context
protected ClientConnection clientConnection;
@Context
protected HttpRequest request;
@Context
protected HttpHeaders headers;
protected RealmModel realm;
protected EventBuilder event;
protected ClientModel client;
protected AuthenticatedClientSessionModel clientSession;
public ValidateEndpoint(RealmModel realm, EventBuilder event) {
this.realm = realm;
this.event = event;
super(realm, event);
}
@GET
......@@ -79,116 +53,4 @@ public class ValidateEndpoint {
return Response.status(e.getStatus()).entity(RESPONSE_FAILED).type(MediaType.TEXT_PLAIN).build();
}
private void checkSsl() {
if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) {
throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN);
}
}
private void checkRealm() {
if (!realm.isEnabled()) {
throw new CASValidationException(CASErrorCode.INTERNAL_ERROR, "Realm not enabled", Response.Status.FORBIDDEN);
}
}
private void checkClient(String service) {
if (service == null) {
event.error(Errors.INVALID_REQUEST);
throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.SERVICE_PARAM, Response.Status.BAD_REQUEST);
}
client = realm.getClients().stream()
.filter(c -> CASLoginProtocol.LOGIN_PROTOCOL.equals(c.getProtocol()))
.filter(c -> RedirectUtils.verifyRedirectUri(session.getContext().getUri(), service, realm, c) != null)
.findFirst().orElse(null);
if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client not found", Response.Status.BAD_REQUEST);
}
if (!client.isEnabled()) {
event.error(Errors.CLIENT_DISABLED);
throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Client disabled", Response.Status.BAD_REQUEST);
}
event.client(client.getClientId());
session.getContext().setClient(client);
}
private void checkTicket(String ticket, boolean requireReauth) {
if (ticket == null) {
event.error(Errors.INVALID_CODE);
throw new CASValidationException(CASErrorCode.INVALID_REQUEST, "Missing parameter: " + CASLoginProtocol.TICKET_PARAM, Response.Status.BAD_REQUEST);
}
if (!ticket.startsWith(CASLoginProtocol.SERVICE_TICKET_PREFIX)) {
event.error(Errors.INVALID_CODE);
throw new CASValidationException(CASErrorCode.INVALID_TICKET_SPEC, "Malformed service ticket", Response.Status.BAD_REQUEST);
}
String code = ticket.substring(CASLoginProtocol.SERVICE_TICKET_PREFIX.length());
String[] parts = code.split("\\.");
if (parts.length == 4) {
event.detail(Details.CODE_ID, parts[2]);
}
ClientSessionCode.ParseResult<AuthenticatedClientSessionModel> parseResult = ClientSessionCode.parseResult(code, null, session, realm, client, event, AuthenticatedClientSessionModel.class);
if (parseResult.isAuthSessionNotFound() || parseResult.isIllegalHash()) {
event.error(Errors.INVALID_CODE);
// Attempt to use same code twice should invalidate existing clientSession
AuthenticatedClientSessionModel clientSession = parseResult.getClientSession();
if (clientSession != null) {
clientSession.detachFromUserSession();
}
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code not valid", Response.Status.BAD_REQUEST);
}
clientSession = parseResult.getClientSession();
if (parseResult.isExpiredToken()) {
event.error(Errors.EXPIRED_CODE);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Code is expired", Response.Status.BAD_REQUEST);
}
clientSession.setNote(CASLoginProtocol.SESSION_SERVICE_TICKET, ticket);
if (requireReauth && AuthenticationManager.isSSOAuthentication(clientSession)) {
event.error(Errors.SESSION_EXPIRED);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Interactive authentication was requested but not performed", Response.Status.BAD_REQUEST);
}
UserSessionModel userSession = clientSession.getUserSession();
if (userSession == null) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User session not found", Response.Status.BAD_REQUEST);
}
UserModel user = userSession.getUser();
if (user == null) {
event.error(Errors.USER_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User not found", Response.Status.BAD_REQUEST);
}
if (!user.isEnabled()) {
event.error(Errors.USER_DISABLED);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "User disabled", Response.Status.BAD_REQUEST);
}
event.user(userSession.getUser());
event.session(userSession.getId());
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
event.error(Errors.INVALID_CODE);
throw new CASValidationException(CASErrorCode.INVALID_SERVICE, "Auth error", Response.Status.BAD_REQUEST);
}
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CASValidationException(CASErrorCode.INVALID_TICKET, "Session not active", Response.Status.BAD_REQUEST);
}
}
}
package org.keycloak.protocol.cas.representations;
import org.keycloak.dom.saml.v1.assertion.*;
import org.keycloak.dom.saml.v1.protocol.SAML11ResponseType;
import org.keycloak.dom.saml.v1.protocol.SAML11StatusCodeType;
import org.keycloak.dom.saml.v1.protocol.SAML11StatusType;
import org.keycloak.protocol.cas.utils.CASValidationException;
import org.keycloak.saml.common.exceptions.ProcessingException;
import org.keycloak.saml.processing.core.saml.v1.SAML11Constants;
import org.keycloak.saml.processing.core.saml.v1.writers.SAML11ResponseWriter;
import org.keycloak.services.validation.Validation;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import javax.xml.datatype.DatatypeConfigurationException;
import javax.xml.datatype.DatatypeFactory;
import javax.xml.datatype.XMLGregorianCalendar;