Skip to content

Commit

Permalink
keycloak: Add example for a Network based authentication step
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasdarimont committed Nov 4, 2023
1 parent 59951b1 commit 15b5a33
Showing 1 changed file with 358 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
package com.github.thomasdarimont.keycloak.custom.auth.net;

import com.google.auto.service.AutoService;
import com.google.common.annotations.VisibleForTesting;
import io.netty.handler.ipfilter.IpFilterRuleType;
import io.netty.handler.ipfilter.IpSubnetFilterRule;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.services.messages.Messages;

import java.net.InetSocketAddress;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
* {@link Authenticator} that can check the remote IP address of the incoming request against a list of allowed networks.
* <p>
* The list of allowed networks can be configured via the AuthenticatorConfig or via a client attribute.
* <p>
* <p>
* This authenticator can be used in the following contexts
* <ul>
* <li>Browser Flow</li>
* <li>Direct Grant Flow</li>
* </ul>
*/
@JBossLog
public class NetworkAuthenticator implements Authenticator {

static final NetworkAuthenticator INSTANCE = new NetworkAuthenticator();

public static final String PROVIDER_ID = "acme-network-authenticator";

public static final String REMOTE_IP_HEADER_PROPERTY = "remoteIpHeader";

public static final String ALLOWED_NETWORKS_PROPERTY = "allowedNetworks";

public static final String X_FORWARDED_FOR = "X-Forwarded-For";

public static final String ACME_ALLOWED_NETWORKS_CLIENT_ATTRIBUTE = "acmeAllowedNetworks";

/**
* Authenticates within Browser and Direct Grant flow authentication flows.
*
* @param flowContext
*/
@Override
public void authenticate(AuthenticationFlowContext flowContext) {

var remoteIp = resolveRemoteIp(flowContext.getAuthenticatorConfig(), //
flowContext.getHttpRequest(), //
flowContext.getConnection().getRemoteAddr());

if (remoteIp == null) {
flowContext.attempted();
return;
}

var authSession = flowContext.getAuthenticationSession();
var realm = authSession.getRealm();
var client = authSession.getClient();

var allowedNetworks = resolveAllowedNetworks(flowContext.getAuthenticatorConfig(), client);
if (allowedNetworks == null) {
// skip check since we don't have any network restrictions configured
log.debugf("Skip check for source IP based on network. realm=%s, client=%s, IP=%s", realm.getName(), client.getClientId(), remoteIp);
flowContext.success();
return;
}

var ipAllowed = false;
for (String allowedNetwork : allowedNetworks.split(",")) {
ipAllowed = isRemoteIpAllowed(allowedNetwork, remoteIp);
if (ipAllowed) {
log.debugf("Allowed source IP based on network. realm=%s, client=%s, IP=%s, network=%s", realm.getName(), client.getClientId(), remoteIp, allowedNetwork);
break;
}
}

if (ipAllowed) {
flowContext.success();
return;
}

log.debugf("Rejected source IP based on allowed networks. realm=%s, client=%s, IP=%s", realm.getName(), client.getClientId(), remoteIp);

var challengeResponse = errorResponse(flowContext, Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Access denied", authSession.getAuthNote("auth_type"));
flowContext.failure(AuthenticationFlowError.ACCESS_DENIED, challengeResponse);
}

/**
* Extracts the allowed networks as comma separated String from the AuthenticatorConfig or the client attribute.
*
* @param config
* @param client
* @return
*/
@VisibleForTesting
String resolveAllowedNetworks(AuthenticatorConfigModel config, ClientModel client) {

var allowedNetworks = getAllowedNetworksForClient(client);
if (isAllowedNetworkConfigured(allowedNetworks)) {
return allowedNetworks;
}

allowedNetworks = getAllowedNetworksForAuthenticator(config);
if (isAllowedNetworkConfigured(allowedNetworks)) {
return allowedNetworks;
}

return null;
}

public Response errorResponse(AuthenticationFlowContext flowContext, int status, String error, String errorDescription, String authType) {

if ("code".equals(authType)) {
// auth code implies browser flow, so we need to render a form here
var form = flowContext.form().setExecution(flowContext.getExecution().getId());
form.setError(Messages.ACCESS_DENIED);
return form.createErrorPage(Response.Status.FORBIDDEN);
}

// client authentication or direct grant flow
OAuth2ErrorRepresentation errorRep = new OAuth2ErrorRepresentation(error, errorDescription);
return Response.status(status).entity(errorRep).type(MediaType.APPLICATION_JSON_TYPE).build();
}

private boolean isAllowedNetworkConfigured(String allowedNetworks) {
return allowedNetworks != null && !allowedNetworks.isBlank();
}

@VisibleForTesting
private String getAllowedNetworksForAuthenticator(AuthenticatorConfigModel authenticatorConfig) {

if (authenticatorConfig == null) {
return null;
}

var config = authenticatorConfig.getConfig();
if (config == null) {
return null;
}

return config.get(ALLOWED_NETWORKS_PROPERTY);
}

@VisibleForTesting
String getAllowedNetworksForClient(ClientModel client) {
return client.getAttribute(ACME_ALLOWED_NETWORKS_CLIENT_ATTRIBUTE);
}

@VisibleForTesting
boolean isRemoteIpAllowed(String allowedNetwork, String remoteIp) {

boolean matches = false;

if (allowedNetwork.contains("/")) {
// CIDR notation
var ipAndCidrRange = allowedNetwork.split("/");
var ip = ipAndCidrRange[0];
int cidrRange = Integer.parseInt(ipAndCidrRange[1]);
var rule = new IpSubnetFilterRule(ip, cidrRange, IpFilterRuleType.ACCEPT);
matches = rule.matches(new InetSocketAddress(remoteIp, 1 /* unsed */));
} else {
// explicit IP addresses
if (remoteIp.equals(allowedNetwork.trim())) {
matches = true;
}
}

return matches;
}

@VisibleForTesting
String resolveRemoteIp(AuthenticatorConfigModel authenticatorConfig, HttpRequest httpRequest, String remoteAddress) {

var remoteIpHeaderName = getRemoteIpHeaderName(authenticatorConfig);
var httpHeaders = httpRequest.getHttpHeaders();
if (X_FORWARDED_FOR.equals(remoteIpHeaderName)) {
// see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
// X-Forwarded-For: <client_ip>, <proxy1_ip>, <proxy2_ip>
String xForwardedForHeaderValue = httpHeaders.getHeaderString(X_FORWARDED_FOR);
if (xForwardedForHeaderValue != null) {
String[] ipAddresses = xForwardedForHeaderValue.split(",");
// take the first IP address
return ipAddresses[0].trim();
}
}

// TODO add support for Standard Forwarded Header
var remoteIpFromHeader = httpHeaders.getHeaderString(remoteIpHeaderName);
if (remoteIpFromHeader != null) {
return remoteIpFromHeader;
}

return remoteAddress;

}

@VisibleForTesting
String getRemoteIpHeaderName(AuthenticatorConfigModel authenticatorConfig) {

if (authenticatorConfig == null) {
return X_FORWARDED_FOR;
}

Map<String, String> config = authenticatorConfig.getConfig();
if (config == null) {
return X_FORWARDED_FOR;
}

String remoteIpHeaderName = config.get(REMOTE_IP_HEADER_PROPERTY);
if (remoteIpHeaderName == null || remoteIpHeaderName.isBlank()) {
return X_FORWARDED_FOR;
}

return remoteIpHeaderName;
}

@Override
public void action(AuthenticationFlowContext flowContext) {
// NOOP
}

@Override
public boolean requiresUser() {
// no resolved user needed
return false;
}

@Override
public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) {
return false;
}

@Override
public void setRequiredActions(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) {
// NOOP
}

@Override
public void close() {
// NOOP
}

@AutoService(AuthenticatorFactory.class)
public static class Factory implements AuthenticatorFactory {

static final List<ProviderConfigProperty> CONFIG_PROPERTIES;

static final String DISPLAY_NAME = "Acme: Network Authenticator";

static final String REFERENCE_CATEGORY = "network";

static final String HELP_TEXT = "Controls access by checking the network address of the incoming request.";

static {
var list = ProviderConfigurationBuilder.create() //
.property().name(REMOTE_IP_HEADER_PROPERTY) //
.type(ProviderConfigProperty.STRING_TYPE) //
.label("Remote IP Header") //
.defaultValue(X_FORWARDED_FOR) //
.helpText("Header which contains the actual remote IP of a user agent. If empty the remote address will be resolved from the TCP connection. If the headername is X-Forwarded-For the header value is split on ',' and the first values is used as the remote address.") //
.add() //

.property().name(ALLOWED_NETWORKS_PROPERTY) //
.type(ProviderConfigProperty.STRING_TYPE) //
.label("Allowed networks") //
.defaultValue(null) //
.helpText("Comma separated list of allowed networks. This supports CIDR network ranges and single IP adresses. If left empty ALL networks are allowed. Configuration can be overriden via client attribute acmeAllowedNetworks. Examples: 192.168.178.0/24, 192.168.178.12/32, 192.168.178.13") //
.add() //


.build();

CONFIG_PROPERTIES = Collections.unmodifiableList(list);
}


@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public String getDisplayType() {
return "Acme: Network Authenticator";
}

@Override
public String getReferenceCategory() {
return "network";
}

@Override
public String getHelpText() {
return "Controls access by checking the network address of the incoming request.";
}

@Override
public Authenticator create(KeycloakSession session) {
return INSTANCE;
}

@Override
public void init(Config.Scope scope) {

}

@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {

}

@Override
public boolean isConfigurable() {
return true;
}

@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}

@Override
public boolean isUserSetupAllowed() {
return false;
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return CONFIG_PROPERTIES;
}

@Override
public void close() {

}
}
}

0 comments on commit 15b5a33

Please sign in to comment.