diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..5304519 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.classpath +!/.project +.project +.settings +target/ +.idea/ +.DS_Store +.idea +overlays/ +.gradle/ +build/ +bin/ +*.iml +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..7249efc --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +ICP-Brasil Authenticator - Keycloak +=================================================== + +1. Keycloak must be configured to request the client certificate, to configure see the following item in Keycloak guide [Enable X.509 Cliente Certificate User Authentication](https://github.com/keycloak/keycloak-documentation/blob/master/server_admin/topics/authentication/x509.adoc#enable-x509-client-certificate-user-authentication) + +2. Keycloak must be in execution + +3. The project must be compiled e installed with the following deploy command + +```bash + $ ./mvnw clean install wildfly:deploy +``` + +4. Copy the "login-icpbrasil-info.ftl" file to the folder "themes/base/login" that's inside the Keycloak install directory + +5. Login in the administrative console. + +6. Go to the "Authentication" page, in the "Flows" tab you will see the current authentication flows. It's not possible to alter the defaults, so you have to create or to copy one. Copy the "Browser" flow. + +7. In your copy, click "Add Execution". Select "ICPBrasil/Validate Username Form" and click "Save" + +8. Move the item "ICPBrasil/Validate Username Form" so that it is before "Browser Forms". Enable it by selecting "ALTERNATIVE" in the "Requirement" column. Configure it by going to the "Actions" column and clicking "Config". + +9. In the configuration, in the item "User Identity Source", select one of the options related to ICPBrasil (Subject's CPF, Subject's CNPJ, Subject's CPF or CNPJ). Under "User mapping method" select "Username or Email". In the "A name of user attribute" fill in with "uid". diff --git a/login-icpbrasil-info.ftl b/login-icpbrasil-info.ftl new file mode 100644 index 0000000..34aed38 --- /dev/null +++ b/login-icpbrasil-info.ftl @@ -0,0 +1,78 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "title"> + ${msg("loginTitle",(realm.displayName!''))} + <#elseif section = "header"> + ${msg("loginTitleHtml",(realm.displayNameHtml!''))?no_esc} + <#elseif section = "form"> + +
+
+ +
+ +
+ <#if subjectDN??> +
+ +
+ <#else> +
+ +
+ +
+ +
+ + <#if isUserEnabled> +
+ +
+
+ +
+ + +
+ +
+
+
+
+
+ +
+
+ + <#if isUserEnabled> + + +
+
+ O formulário será enviado em -- segundos +
+
+ + + + diff --git a/maven/maven-wrapper.jar b/maven/maven-wrapper.jar new file mode 100644 index 0000000..18ba302 Binary files /dev/null and b/maven/maven-wrapper.jar differ diff --git a/maven/maven-wrapper.properties b/maven/maven-wrapper.properties new file mode 100644 index 0000000..b368e46 --- /dev/null +++ b/maven/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://repository.apache.org/content/repositories/releases/org/apache/maven/apache-maven/3.5.0/apache-maven-3.5.0-bin.zip diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..2275ac7 --- /dev/null +++ b/mvnw @@ -0,0 +1,234 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # + # Look for the Apple JDKs first to preserve the existing behaviour, and then look + # for the new JDKs provided by Oracle. + # + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then + # + # Oracle JDKs + # + export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then + # + # Apple JDKs + # + export JAVA_HOME=`/usr/libexec/java_home` + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + local basedir=$(pwd) + local wdir=$(pwd) + while [ "$wdir" != '/' ] ; do + wdir=$(cd "$wdir/.."; pwd) + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)} +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER="org.apache.maven.wrapper.MavenWrapperMain" + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + -classpath \ +"$MAVEN_PROJECTBASEDIR/maven/maven-wrapper.jar" \ + ${WRAPPER_LAUNCHER} "$@" diff --git a/mvnw.bat b/mvnw.bat new file mode 100755 index 0000000..f8ede7f --- /dev/null +++ b/mvnw.bat @@ -0,0 +1,174 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto chkMHome + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:chkMHome +if not "%M2_HOME%"=="" goto valMHome + +SET "M2_HOME=%~dp0.." +if not "%M2_HOME%"=="" goto valMHome + +echo. +echo Error: M2_HOME not found in your environment. >&2 +echo Please set the M2_HOME variable in your environment to match the >&2 +echo location of the Maven installation. >&2 +echo. +goto error + +:valMHome + +:stripMHome +if not "_%M2_HOME:~-1%"=="_\" goto checkMCmd +set "M2_HOME=%M2_HOME:~0,-1%" +goto stripMHome + +:checkMCmd +if exist "%M2_HOME%\bin\mvn.cmd" goto init + +echo. +echo Error: M2_HOME is set to an invalid directory. >&2 +echo M2_HOME = "%M2_HOME%" >&2 +echo Please set the M2_HOME variable in your environment to match the >&2 +echo location of the Maven installation >&2 +echo. +goto error +@REM ==== END VALIDATION ==== + +:init + +set MAVEN_CMD_LINE_ARGS=%* + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\maven\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% + +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8e1d1a1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,98 @@ + + + + org.keycloak + keycloak-parent + 3.4.3.Final + + + ICPBrasil Authenticator + + 4.0.0 + + authenticator-icpbrasil + jar + + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-server-spi + provided + + + org.keycloak + keycloak-server-spi-private + provided + + + org.jboss.logging + jboss-logging + provided + + + org.keycloak + keycloak-services + provided + + + org.freemarker + freemarker + provided + + + + + authenticator-icpbrasil + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + org.keycloak.keycloak-services + org.freemarker + org.bouncycastle + org.jboss.logging + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.wildfly.plugins + wildfly-maven-plugin + + false + + + + + + + \ No newline at end of file diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateAuthenticator.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateAuthenticator.java new file mode 100644 index 0000000..ca1d078 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateAuthenticator.java @@ -0,0 +1,271 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import java.security.GeneralSecurityException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +import javax.ws.rs.core.Response; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.x509.X509ClientCertificateLookup; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ + +public abstract class AbstractICPBrasilClientCertificateAuthenticator implements Authenticator { + + public static final String DEFAULT_ATTRIBUTE_NAME = "usercertificate"; + protected static ServicesLogger logger = ServicesLogger.LOGGER; + + public static final String JAVAX_SERVLET_REQUEST_X509_CERTIFICATE = "javax.servlet.request.X509Certificate"; + + public static final String REGULAR_EXPRESSION = "x509-cert-auth.regular-expression"; + public static final String ENABLE_CRL = "x509-cert-auth.crl-checking-enabled"; + public static final String ENABLE_OCSP = "x509-cert-auth.ocsp-checking-enabled"; + public static final String ENABLE_CRLDP = "x509-cert-auth.crldp-checking-enabled"; + public static final String CRL_RELATIVE_PATH = "x509-cert-auth.crl-relative-path"; + public static final String OCSPRESPONDER_URI = "x509-cert-auth.ocsp-responder-uri"; + public static final String MAPPING_SOURCE_SELECTION = "x509-cert-auth.mapping-source-selection"; + public static final String MAPPING_SOURCE_CERT_SUBJECTDN = "Match SubjectDN using regular expression"; + public static final String MAPPING_SOURCE_CERT_SUBJECTDN_EMAIL = "Subject's e-mail"; + public static final String MAPPING_SOURCE_CERT_SUBJECTDN_CN = "Subject's Common Name"; + public static final String MAPPING_SOURCE_CERT_SUBJECT_CPF = "Subject's CPF"; + public static final String MAPPING_SOURCE_CERT_SUBJECT_CNPJ = "Subject's CNPJ"; + public static final String MAPPING_SOURCE_CERT_SUBJECT_CPF_CNPJ = "Subject's CPF or CNPJ"; + public static final String MAPPING_SOURCE_CERT_ISSUERDN = "Match IssuerDN using regular expression"; + public static final String MAPPING_SOURCE_CERT_ISSUERDN_EMAIL = "Issuer's e-mail"; + public static final String MAPPING_SOURCE_CERT_ISSUERDN_CN = "Issuer's Common Name"; + public static final String MAPPING_SOURCE_CERT_SERIALNUMBER = "Certificate Serial Number"; + public static final String USER_MAPPER_SELECTION = "x509-cert-auth.mapper-selection"; + public static final String USER_ATTRIBUTE_MAPPER = "Custom Attribute Mapper"; + public static final String USERNAME_EMAIL_MAPPER = "Username or Email"; + public static final String CUSTOM_ATTRIBUTE_NAME = "x509-cert-auth.mapper-selection.user-attribute-name"; + public static final String CERTIFICATE_KEY_USAGE = "x509-cert-auth.keyusage"; + public static final String CERTIFICATE_EXTENDED_KEY_USAGE = "x509-cert-auth.extendedkeyusage"; + static final String DEFAULT_MATCH_ALL_EXPRESSION = "(.*?)(?:$)"; + public static final String CONFIRMATION_PAGE_DISALLOWED = "x509-cert-auth.confirmation-page-disallowed"; + + + protected Response createInfoResponse(AuthenticationFlowContext context, String infoMessage, Object ... parameters) { + LoginFormsProvider form = context.form(); + return form.setInfo(infoMessage, parameters).createInfoPage(); + } + + protected static class CertificateValidatorConfigBuilder { + + static CertificateValidator.CertificateValidatorBuilder fromConfig(ICPBrasilAuthenticatorConfigModel config) throws Exception { + + CertificateValidator.CertificateValidatorBuilder builder = new CertificateValidator.CertificateValidatorBuilder(); + return builder + .keyUsage() + .parse(config.getKeyUsage()) + .extendedKeyUsage() + .parse(config.getExtendedKeyUsage()) + .revocation() + .cRLEnabled(config.getCRLEnabled()) + .cRLDPEnabled(config.getCRLDistributionPointEnabled()) + .cRLrelativePath(config.getCRLRelativePath()) + .oCSPEnabled(config.getOCSPEnabled()) + .oCSPResponderURI(config.getOCSPResponder()); + } + } + + // The method is purely for purposes of facilitating the unit testing + public CertificateValidator.CertificateValidatorBuilder certificateValidationParameters(ICPBrasilAuthenticatorConfigModel config) throws Exception { + return CertificateValidatorConfigBuilder.fromConfig(config); + } + + protected static class UserIdentityExtractorBuilder { + + private static final Function subject = certs -> { + try { + return new JcaX509CertificateHolder(certs[0]).getSubject(); + } catch (CertificateEncodingException e) { + logger.warn("Unable to get certificate Subject", e); + } + return null; + }; + + private static final Function>> subjectAlternativeNames; + + static { + subjectAlternativeNames = certs -> { + try { + return certs[0].getSubjectAlternativeNames(); + } catch (CertificateParsingException e) { + + logger.warn("Unable to get certificate Subject Alternative Names", e); + } + return null; + }; + } + + private static final Function issuer = certs -> { + try { + return new JcaX509CertificateHolder(certs[0]).getIssuer(); + } catch (CertificateEncodingException e) { + logger.warn("Unable to get certificate Issuer", e); + } + return null; + }; + + static UserIdentityExtractor fromConfig(ICPBrasilAuthenticatorConfigModel config) { + + ICPBrasilAuthenticatorConfigModel.MappingSourceType userIdentitySource = config.getMappingSourceType(); + String pattern = config.getRegularExpression(); + + UserIdentityExtractor extractor = null; + switch(userIdentitySource) { + + case SUBJECTDN: + extractor = UserIdentityExtractor.getPatternIdentityExtractor(pattern, certs -> certs[0].getSubjectDN().getName()); + break; + case ISSUERDN: + extractor = UserIdentityExtractor.getPatternIdentityExtractor(pattern, certs -> certs[0].getIssuerDN().getName()); + break; + case SERIALNUMBER: + extractor = UserIdentityExtractor.getPatternIdentityExtractor(DEFAULT_MATCH_ALL_EXPRESSION, certs -> certs[0].getSerialNumber().toString()); + break; + case SUBJECTDN_CN: + extractor = UserIdentityExtractor.getX500NameExtractor(BCStyle.CN, subject); + break; + case SUBJECTDN_EMAIL: + extractor = UserIdentityExtractor + .either(UserIdentityExtractor.getX500NameExtractor(BCStyle.EmailAddress, subject)) + .or(UserIdentityExtractor.getX500NameExtractor(BCStyle.E, subject)); + break; + case SUBJECTCPF: + extractor = UserIdentityExtractor.getICPBrasilExtractor(subjectAlternativeNames, userIdentitySource); + break; + case SUBJECTCNPJ: + extractor = UserIdentityExtractor.getICPBrasilExtractor(subjectAlternativeNames, userIdentitySource); + break; + case SUBJECTCPFCNPJ: + extractor = UserIdentityExtractor.getICPBrasilExtractor(subjectAlternativeNames, userIdentitySource); + break; + case ISSUERDN_CN: + extractor = UserIdentityExtractor.getX500NameExtractor(BCStyle.CN, issuer); + break; + case ISSUERDN_EMAIL: + extractor = UserIdentityExtractor + .either(UserIdentityExtractor.getX500NameExtractor(BCStyle.EmailAddress, issuer)) + .or(UserIdentityExtractor.getX500NameExtractor(BCStyle.E, issuer)); + break; + default: + logger.warnf("[UserIdentityExtractorBuilder:fromConfig] Unknown or unsupported user identity source: \"%s\"", userIdentitySource.getName()); + break; + } + return extractor; + } + } + + protected static class UserIdentityToModelMapperBuilder { + + static UserIdentityToModelMapper fromConfig(ICPBrasilAuthenticatorConfigModel config) { + + ICPBrasilAuthenticatorConfigModel.IdentityMapperType mapperType = config.getUserIdentityMapperType(); + String attributeName = config.getCustomAttributeName(); + + UserIdentityToModelMapper mapper = null; + switch (mapperType) { + case USER_ATTRIBUTE: + mapper = UserIdentityToModelMapper.getUserIdentityToCustomAttributeMapper(attributeName); + break; + case USERNAME_EMAIL: + mapper = UserIdentityToModelMapper.getUsernameOrEmailMapper(); + break; + default: + logger.warnf("[UserIdentityToModelMapperBuilder:fromConfig] Unknown or unsupported user identity mapper: \"%s\"", mapperType.getName()); + } + return mapper; + } + } + + @Override + public void close() { + + } + + protected X509Certificate[] getCertificateChain(AuthenticationFlowContext context) { + try { + // Get a x509 client certificate + X509ClientCertificateLookup provider = context.getSession().getProvider(X509ClientCertificateLookup.class); + if (provider == null) { + logger.errorv("\"{0}\" Spi is not available, did you forget to update the configuration?", + X509ClientCertificateLookup.class); + return null; + } + + X509Certificate[] certs = provider.getCertificateChain(context.getHttpRequest()); + + if (certs != null) { + for (X509Certificate cert : certs) { + logger.tracev("\"{0}\"", cert.getSubjectDN().getName()); + } + } + + return certs; + } + catch (GeneralSecurityException e) { + logger.error(e.getMessage(), e); + } + return null; + } + + // Purely for unit testing + public UserIdentityExtractor getUserIdentityExtractor(ICPBrasilAuthenticatorConfigModel config) { + return UserIdentityExtractorBuilder.fromConfig(config); + } + // Purely for unit testing + public UserIdentityToModelMapper getUserIdentityToModelMapper(ICPBrasilAuthenticatorConfigModel config) { + return UserIdentityToModelMapperBuilder.fromConfig(config); + } + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateAuthenticatorFactory.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateAuthenticatorFactory.java new file mode 100644 index 0000000..3dd4837 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateAuthenticatorFactory.java @@ -0,0 +1,202 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import java.util.LinkedList; +import java.util.List; + +import static java.util.Arrays.asList; + +import org.keycloak.Config; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.services.ServicesLogger; + +import static org.keycloak.authentication.authenticators.icpbrasil.AbstractICPBrasilClientCertificateAuthenticator.*; +import static org.keycloak.provider.ProviderConfigProperty.BOOLEAN_TYPE; +import static org.keycloak.provider.ProviderConfigProperty.STRING_TYPE; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ + +public abstract class AbstractICPBrasilClientCertificateAuthenticatorFactory implements AuthenticatorFactory { + + protected static ServicesLogger logger = ServicesLogger.LOGGER; + + private static final String[] mappingSources = { + MAPPING_SOURCE_CERT_SUBJECTDN, + MAPPING_SOURCE_CERT_SUBJECTDN_EMAIL, + MAPPING_SOURCE_CERT_SUBJECTDN_CN, + MAPPING_SOURCE_CERT_ISSUERDN, + MAPPING_SOURCE_CERT_ISSUERDN_EMAIL, + MAPPING_SOURCE_CERT_ISSUERDN_CN, + MAPPING_SOURCE_CERT_SERIALNUMBER, + MAPPING_SOURCE_CERT_SUBJECT_CPF, + MAPPING_SOURCE_CERT_SUBJECT_CNPJ, + MAPPING_SOURCE_CERT_SUBJECT_CPF_CNPJ + }; + + private static final String[] userModelMappers = { + USER_ATTRIBUTE_MAPPER, + USERNAME_EMAIL_MAPPER + }; + + protected static final List configProperties; + static { + List mappingSourceTypes = new LinkedList<>(); + for (String s : mappingSources) { + mappingSourceTypes.add(s); + } + ProviderConfigProperty mappingMethodList = new ProviderConfigProperty(); + mappingMethodList.setType(ProviderConfigProperty.LIST_TYPE); + mappingMethodList.setName(MAPPING_SOURCE_SELECTION); + mappingMethodList.setLabel("User Identity Source"); + mappingMethodList.setHelpText("Choose how to extract user identity from X509 certificate or the certificate fields. For example, SubjectDN will match the custom regular expression specified below to the value of certificate's SubjectDN field."); + mappingMethodList.setDefaultValue(mappingSources[0]); + mappingMethodList.setOptions(mappingSourceTypes); + + ProviderConfigProperty regExp = new ProviderConfigProperty(); + regExp.setType(STRING_TYPE); + regExp.setName(REGULAR_EXPRESSION); + regExp.setLabel("A regular expression to extract user identity"); + regExp.setDefaultValue(DEFAULT_MATCH_ALL_EXPRESSION); + regExp.setHelpText("The regular expression to extract a user identity. The expression must contain a single group. For example, 'uniqueId=(.*?)(?:,|$)' will match 'uniqueId=somebody@company.org, CN=somebody' and give somebody@company.org"); + + List mapperTypes = new LinkedList<>(); + for (String m : userModelMappers) { + mapperTypes.add(m); + } + + ProviderConfigProperty userMapperList = new ProviderConfigProperty(); + userMapperList.setType(ProviderConfigProperty.LIST_TYPE); + userMapperList.setName(USER_MAPPER_SELECTION); + userMapperList.setHelpText("Choose how to map extracted user identities to users"); + userMapperList.setLabel("User mapping method"); + userMapperList.setDefaultValue(userModelMappers[0]); + userMapperList.setOptions(mapperTypes); + + ProviderConfigProperty attributeOrPropertyValue = new ProviderConfigProperty(); + attributeOrPropertyValue.setType(STRING_TYPE); + attributeOrPropertyValue.setName(CUSTOM_ATTRIBUTE_NAME); + attributeOrPropertyValue.setDefaultValue(DEFAULT_ATTRIBUTE_NAME); + attributeOrPropertyValue.setLabel("A name of user attribute"); + attributeOrPropertyValue.setHelpText("A name of user attribute to map the extracted user identity to existing user. The name must be a valid, existing user attribute if User Mapping Method is set to Custom Attribute Mapper."); + + ProviderConfigProperty crlCheckingEnabled = new ProviderConfigProperty(); + crlCheckingEnabled.setType(BOOLEAN_TYPE); + crlCheckingEnabled.setName(ENABLE_CRL); + crlCheckingEnabled.setHelpText("Enable Certificate Revocation Checking using CRL"); + crlCheckingEnabled.setLabel("CRL Checking Enabled"); + + ProviderConfigProperty crlDPEnabled = new ProviderConfigProperty(); + crlDPEnabled.setType(BOOLEAN_TYPE); + crlDPEnabled.setName(ENABLE_CRLDP); + crlDPEnabled.setDefaultValue(false); + crlDPEnabled.setLabel("Enable CRL Distribution Point to check certificate revocation status"); + crlDPEnabled.setHelpText("CRL Distribution Point is a starting point for CRL. CDP is optional, but most PKI authorities include CDP in their certificates."); + + ProviderConfigProperty cRLRelativePath = new ProviderConfigProperty(); + cRLRelativePath.setType(STRING_TYPE); + cRLRelativePath.setName(CRL_RELATIVE_PATH); + cRLRelativePath.setDefaultValue("crl.pem"); + cRLRelativePath.setLabel("CRL File path"); + cRLRelativePath.setHelpText("The path to a CRL file that contains a list of revoked certificates. Paths are assumed to be relative to $jboss.server.config.dir"); + + ProviderConfigProperty oCspCheckingEnabled = new ProviderConfigProperty(); + oCspCheckingEnabled.setType(BOOLEAN_TYPE); + oCspCheckingEnabled.setName(ENABLE_OCSP); + oCspCheckingEnabled.setHelpText("Enable Certificate Revocation Checking using OCSP"); + oCspCheckingEnabled.setLabel("OCSP Checking Enabled"); + + ProviderConfigProperty ocspResponderUri = new ProviderConfigProperty(); + ocspResponderUri.setType(STRING_TYPE); + ocspResponderUri.setName(OCSPRESPONDER_URI); + ocspResponderUri.setLabel("OCSP Responder Uri"); + ocspResponderUri.setHelpText("Clients use OCSP Responder Uri to check certificate revocation status."); + + ProviderConfigProperty keyUsage = new ProviderConfigProperty(); + keyUsage.setType(STRING_TYPE); + keyUsage.setName(CERTIFICATE_KEY_USAGE); + keyUsage.setLabel("Validate Key Usage"); + keyUsage.setHelpText("Validates that the purpose of the key contained in the certificate (encipherment, signature, etc.) matches its intended purpose. Leaving the field blank will disable Key Usage validation. For example, 'digitalSignature, keyEncipherment' will check if the digitalSignature and keyEncipherment bits (bit 0 and bit 2 respectively) are set in certificate's X509 Key Usage extension. See RFC 5280 for a detailed definition of X509 Key Usage extension."); + + ProviderConfigProperty extendedKeyUsage = new ProviderConfigProperty(); + extendedKeyUsage.setType(STRING_TYPE); + extendedKeyUsage.setName(CERTIFICATE_EXTENDED_KEY_USAGE); + extendedKeyUsage.setLabel("Validate Extended Key Usage"); + extendedKeyUsage.setHelpText("Validates the extended purposes of the certificate's key using certificate's Extended Key Usage extension. Leaving the field blank will disable Extended Key Usage validation. See RFC 5280 for a detailed definition of X509 Extended Key Usage extension."); + + ProviderConfigProperty identityConfirmationPageDisallowed = new ProviderConfigProperty(); + identityConfirmationPageDisallowed.setType(BOOLEAN_TYPE); + identityConfirmationPageDisallowed.setName(CONFIRMATION_PAGE_DISALLOWED); + identityConfirmationPageDisallowed.setLabel("Bypass identity confirmation"); + identityConfirmationPageDisallowed.setHelpText("By default, the users are prompted to confirm their identity extracted from X509 client certificate. The identity confirmation prompt is skipped if the option is switched on."); + + configProperties = asList(mappingMethodList, + regExp, + userMapperList, + attributeOrPropertyValue, + crlCheckingEnabled, + crlDPEnabled, + cRLRelativePath, + oCspCheckingEnabled, + ocspResponderUri, + keyUsage, + extendedKeyUsage, + identityConfirmationPageDisallowed); + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public String getReferenceCategory() { + return null; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateDirectGrantAuthenticator.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateDirectGrantAuthenticator.java new file mode 100644 index 0000000..d26a14b --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/AbstractICPBrasilClientCertificateDirectGrantAuthenticator.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import org.keycloak.OAuth2Constants; +import org.keycloak.authentication.AuthenticationFlowContext; + +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 7/31/2016 + */ + +public abstract class AbstractICPBrasilClientCertificateDirectGrantAuthenticator extends AbstractICPBrasilClientCertificateAuthenticator { + + public Response errorResponse(int status, String error, String errorDescription) { + 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(MediaType.APPLICATION_JSON_TYPE).build(); + } + + @Override + public void action(AuthenticationFlowContext context) { + + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/CertificateValidator.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/CertificateValidator.java new file mode 100644 index 0000000..b23bef9 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/CertificateValidator.java @@ -0,0 +1,708 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import org.keycloak.common.util.CRLUtils; +import org.keycloak.common.util.OCSPUtils; +import org.keycloak.services.ServicesLogger; + +import javax.naming.Context; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.File; +import java.io.FileInputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLConnection; +import java.security.GeneralSecurityException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509CRL; +import java.security.cert.X509Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CRLException; +import java.util.Collection; +import java.util.Collections; +import java.util.Hashtable; +import java.util.List; +import java.util.Set; +import java.util.LinkedList; +import java.util.ArrayList; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 7/30/2016 + */ + +public class CertificateValidator { + + private static final ServicesLogger logger = ServicesLogger.LOGGER; + + enum KeyUsageBits { + DIGITAL_SIGNATURE(0, "digitalSignature"), + NON_REPUDIATION(1, "nonRepudiation"), + KEY_ENCIPHERMENT(2, "keyEncipherment"), + DATA_ENCIPHERMENT(3, "dataEncipherment"), + KEY_AGREEMENT(4, "keyAgreement"), + KEYCERTSIGN(5, "keyCertSign"), + CRLSIGN(6, "cRLSign"), + ENCIPHERMENT_ONLY(7, "encipherOnly"), + DECIPHER_ONLY(8, "decipherOnly"); + + private int value; + private String name; + + KeyUsageBits(int value, String name) { + + if (value < 0 || value > 8) + throw new IllegalArgumentException("value"); + if (name == null || name.trim().length() == 0) + throw new IllegalArgumentException("name"); + this.value = value; + this.name = name.trim(); + } + + public int getInt() { return this.value; } + public String getName() { return this.name; } + + static public KeyUsageBits parse(String name) throws IllegalArgumentException, IndexOutOfBoundsException { + if (name == null || name.trim().length() == 0) + throw new IllegalArgumentException("name"); + + for (KeyUsageBits bit : KeyUsageBits.values()) { + if (bit.getName().equalsIgnoreCase(name)) + return bit; + } + throw new IndexOutOfBoundsException("name"); + } + + static public KeyUsageBits fromValue(int value) throws IndexOutOfBoundsException { + if (value < 0 || value > 8) + throw new IndexOutOfBoundsException("value"); + for (KeyUsageBits bit : KeyUsageBits.values()) + if (bit.getInt() == value) + return bit; + throw new IndexOutOfBoundsException("value"); + } + } + + public static class LdapContext { + private final String ldapFactoryClassName; + + public LdapContext() { + ldapFactoryClassName = "com.sun.jndi.ldap.LdapCtxFactory"; + } + + public LdapContext(String ldapFactoryClassName) { + this.ldapFactoryClassName = ldapFactoryClassName; + } + + public String getLdapFactoryClassName() { + return ldapFactoryClassName; + } + } + + public static abstract class OCSPChecker { + /** + * Requests certificate revocation status using OCSP. The OCSP responder URI + * is obtained from the certificate's AIA extension. + * @param cert the certificate to be checked + * @param issuerCertificate The issuer certificate + * @return revocation status + */ + public abstract OCSPUtils.OCSPRevocationStatus check(X509Certificate cert, X509Certificate issuerCertificate) throws CertPathValidatorException; + } + + public static abstract class CRLLoaderImpl { + /** + * Returns a collection of {@link X509CRL} + * @return + * @throws GeneralSecurityException + */ + public abstract Collection getX509CRLs() throws GeneralSecurityException; + } + + public static class BouncyCastleOCSPChecker extends OCSPChecker { + + private final String responderUri; + BouncyCastleOCSPChecker(String responderUri) { + this.responderUri = responderUri; + } + + @Override + public OCSPUtils.OCSPRevocationStatus check(X509Certificate cert, X509Certificate issuerCertificate) throws CertPathValidatorException { + + OCSPUtils.OCSPRevocationStatus ocspRevocationStatus = null; + if (responderUri == null || responderUri.trim().length() == 0) { + // Obtains revocation status of a certificate using OCSP and assuming + // most common defaults. If responderUri is not specified, + // then OCS responder URI is retrieved from the + // certificate's AIA extension. + // OCSP responses must be signed with the issuer certificate + // or with another certificate that must be: + // 1) signed by the issuer certificate, + // 2) Includes the value of OCSPsigning in ExtendedKeyUsage v3 extension + // 3) Certificate is valid at the time + ocspRevocationStatus = OCSPUtils.check(cert, issuerCertificate); + } + else { + URI uri; + try { + uri = new URI(responderUri); + } catch (URISyntaxException e) { + String message = String.format("Unable to check certificate revocation status using OCSP.\n%s", e.getMessage()); + throw new CertPathValidatorException(message, e); + } + logger.tracef("Responder URI \"%s\" will be used to verify revocation status of the certificate using OCSP", uri.toString()); + // Obtains the revocation status of a certificate using OCSP. + // OCSP responder's certificate is assumed to be the issuer's certificate + // certificate. + // responderUri overrides the contents (if any) of the certificate's AIA extension + ocspRevocationStatus = OCSPUtils.check(cert, issuerCertificate, uri, null, null); + } + return ocspRevocationStatus; + } + } + + public static class CRLLoaderProxy extends CRLLoaderImpl { + private final X509CRL _crl; + public CRLLoaderProxy(X509CRL crl) { + _crl = crl; + } + public Collection getX509CRLs() throws GeneralSecurityException { + return Collections.singleton(_crl); + } + } + + public static class CRLFileLoader extends CRLLoaderImpl { + + private final String cRLPath; + private final LdapContext ldapContext; + + public CRLFileLoader(String cRLPath) { + this.cRLPath = cRLPath; + ldapContext = new LdapContext(); + } + + public CRLFileLoader(String cRLPath, LdapContext ldapContext) { + this.cRLPath = cRLPath; + this.ldapContext = ldapContext; + + if (ldapContext == null) + throw new NullPointerException("Context cannot be null"); + } + public Collection getX509CRLs() throws GeneralSecurityException { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection crlColl = null; + + if (cRLPath != null) { + if (cRLPath.startsWith("http") || cRLPath.startsWith("https")) { + // load CRL using remote URI + try { + crlColl = loadFromURI(cf, new URI(cRLPath)); + } catch (URISyntaxException e) { + logger.error(e.getMessage()); + } + } else if (cRLPath.startsWith("ldap")) { + // load CRL from LDAP + try { + crlColl = loadCRLFromLDAP(cf, new URI(cRLPath)); + } catch(URISyntaxException e) { + logger.error(e.getMessage()); + } + } else { + // load CRL from file + crlColl = loadCRLFromFile(cf, cRLPath); + } + } + if (crlColl == null || crlColl.size() == 0) { + String message = String.format("Unable to load CRL from \"%s\"", cRLPath); + throw new GeneralSecurityException(message); + } + return crlColl; + } + + private Collection loadFromURI(CertificateFactory cf, URI remoteURI) throws GeneralSecurityException { + try { + logger.debugf("Loading CRL from %s", remoteURI.toString()); + + URLConnection conn = remoteURI.toURL().openConnection(); + conn.setDoInput(true); + conn.setUseCaches(false); + X509CRL crl = loadFromStream(cf, conn.getInputStream()); + return Collections.singleton(crl); + } + catch(IOException ex) { + logger.errorf(ex.getMessage()); + } + return Collections.emptyList(); + + } + + private Collection loadCRLFromLDAP(CertificateFactory cf, URI remoteURI) throws GeneralSecurityException { + Hashtable env = new Hashtable<>(2); + env.put(Context.INITIAL_CONTEXT_FACTORY, ldapContext.getLdapFactoryClassName()); + env.put(Context.PROVIDER_URL, remoteURI.toString()); + + try { + DirContext ctx = new InitialDirContext(env); + try { + Attributes attrs = ctx.getAttributes(""); + Attribute cRLAttribute = attrs.get("certificateRevocationList;binary"); + byte[] data = (byte[])cRLAttribute.get(); + if (data == null || data.length == 0) { + throw new CertificateException(String.format("Failed to download CRL from \"%s\"", remoteURI.toString())); + } + X509CRL crl = loadFromStream(cf, new ByteArrayInputStream(data)); + return Collections.singleton(crl); + } finally { + ctx.close(); + } + } catch (NamingException e) { + logger.error(e.getMessage()); + } catch(IOException e) { + logger.error(e.getMessage()); + } + + return Collections.emptyList(); + } + + private Collection loadCRLFromFile(CertificateFactory cf, String relativePath) throws GeneralSecurityException { + try { + String configDir = System.getProperty("jboss.server.config.dir"); + if (configDir != null) { + File f = new File(configDir + File.separator + relativePath); + if (f.isFile()) { + logger.debugf("Loading CRL from %s", f.getAbsolutePath()); + + if (!f.canRead()) { + throw new IOException(String.format("Unable to read CRL from \"%s\"", f.getAbsolutePath())); + } + X509CRL crl = loadFromStream(cf, new FileInputStream(f.getAbsolutePath())); + return Collections.singleton(crl); + } + } + } + catch(IOException ex) { + logger.errorf(ex.getMessage()); + } + return Collections.emptyList(); + } + private X509CRL loadFromStream(CertificateFactory cf, InputStream is) throws IOException, CRLException { + DataInputStream dis = new DataInputStream(is); + X509CRL crl = (X509CRL)cf.generateCRL(dis); + dis.close(); + return crl; + } + } + + + X509Certificate[] _certChain; + int _keyUsageBits; + List _extendedKeyUsage; + boolean _crlCheckingEnabled; + boolean _crldpEnabled; + CRLLoaderImpl _crlLoader; + boolean _ocspEnabled; + OCSPChecker ocspChecker; + + public CertificateValidator() { + + } + protected CertificateValidator(X509Certificate[] certChain, + int keyUsageBits, List extendedKeyUsage, + boolean cRLCheckingEnabled, + boolean cRLDPCheckingEnabled, + CRLLoaderImpl crlLoader, + boolean oCSPCheckingEnabled, + OCSPChecker ocspChecker) { + _certChain = certChain; + _keyUsageBits = keyUsageBits; + _extendedKeyUsage = extendedKeyUsage; + _crlCheckingEnabled = cRLCheckingEnabled; + _crldpEnabled = cRLDPCheckingEnabled; + _crlLoader = crlLoader; + _ocspEnabled = oCSPCheckingEnabled; + this.ocspChecker = ocspChecker; + + if (ocspChecker == null) + throw new IllegalArgumentException("ocspChecker"); + } + + private static void validateKeyUsage(X509Certificate[] certs, int expected) throws GeneralSecurityException { + boolean[] keyUsageBits = certs[0].getKeyUsage(); + if (keyUsageBits == null) { + if (expected != 0) { + String message = "Key usage extension is expected, but unavailable."; + throw new GeneralSecurityException(message); + } + return; + } + + boolean isCritical = false; + Set critSet = certs[0].getCriticalExtensionOIDs(); + if (critSet != null) { + isCritical = critSet.contains("2.5.29.15"); + } + + int n = expected; + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyUsageBits.length; i++, n >>= 1) { + boolean value = keyUsageBits[i]; + if ((n & 1) == 1 && !value) { + String message = String.format("Key Usage bit \'%s\' is not set.", CertificateValidator.KeyUsageBits.fromValue(i).getName()); + if (sb.length() > 0) sb.append("\n"); + sb.append(message); + + logger.warn(message); + } + } + if (sb.length() > 0) { + if (isCritical) { + throw new GeneralSecurityException(sb.toString()); + } + } + } + + private static void validateExtendedKeyUsage(X509Certificate[] certs, List expectedEKU) throws GeneralSecurityException { + if (expectedEKU == null || expectedEKU.size() == 0) { + logger.debug("Extended Key Usage validation is not enabled."); + return; + } + List extendedKeyUsage = certs[0].getExtendedKeyUsage(); + if (extendedKeyUsage == null) { + String message = "Extended key usage extension is expected, but unavailable"; + throw new GeneralSecurityException(message); + } + + boolean isCritical = false; + Set critSet = certs[0].getCriticalExtensionOIDs(); + if (critSet != null) { + isCritical = critSet.contains("2.5.29.37"); + } + + List ekuList = new LinkedList<>(); + extendedKeyUsage.forEach(s -> ekuList.add(s.toLowerCase())); + + for (String eku : expectedEKU) { + if (!ekuList.contains(eku.toLowerCase())) { + String message = String.format("Extended Key Usage \'%s\' is missing.", eku); + if (isCritical) { + throw new GeneralSecurityException(message); + } + logger.warn(message); + } + } + } + + public CertificateValidator validateKeyUsage() throws GeneralSecurityException { + validateKeyUsage(_certChain, _keyUsageBits); + return this; + } + public CertificateValidator validateExtendedKeyUsage() throws GeneralSecurityException { + validateExtendedKeyUsage(_certChain, _extendedKeyUsage); + return this; + } + private void checkRevocationUsingOCSP(X509Certificate[] certs) throws GeneralSecurityException { + + if (certs.length < 2) { + // OCSP requires a responder certificate to verify OCSP + // signed response. + String message = "OCSP requires a responder certificate. OCSP cannot be used to verify the revocation status of self-signed certificates."; + throw new GeneralSecurityException(message); + } + + for (X509Certificate cert : certs) { + logger.debugf("Certificate: %s", cert.getSubjectDN().getName()); + } + + OCSPUtils.OCSPRevocationStatus rs = ocspChecker.check(certs[0], certs[1]); + + if (rs == null) { + throw new GeneralSecurityException("Unable to check client revocation status using OCSP"); + } + + if (rs.getRevocationStatus() == OCSPUtils.RevocationStatus.UNKNOWN) { + throw new GeneralSecurityException("Unable to determine certificate's revocation status."); + } + else if (rs.getRevocationStatus() == OCSPUtils.RevocationStatus.REVOKED) { + + StringBuilder sb = new StringBuilder(); + sb.append("Certificate's been revoked."); + sb.append("\n"); + sb.append(rs.getRevocationReason().toString()); + sb.append("\n"); + sb.append(String.format("Revoked on: %s",rs.getRevocationTime().toString())); + + throw new GeneralSecurityException(sb.toString()); + } + } + + private static void checkRevocationStatusUsingCRL(X509Certificate[] certs, CRLLoaderImpl crLoader) throws GeneralSecurityException { + Collection crlColl = crLoader.getX509CRLs(); + if (crlColl != null && crlColl.size() > 0) { + for (X509CRL it : crlColl) { + if (it.isRevoked(certs[0])) { + String message = String.format("Certificate has been revoked, certificate's subject: %s", certs[0].getSubjectDN().getName()); + logger.debug(message); + throw new GeneralSecurityException(message); + } + } + } + } + private static List getCRLDistributionPoints(X509Certificate cert) { + try { + return CRLUtils.getCRLDistributionPoints(cert); + } + catch(IOException e) { + logger.error(e.getMessage()); + } + return new ArrayList<>(); + } + + private static void checkRevocationStatusUsingCRLDistributionPoints(X509Certificate[] certs) throws GeneralSecurityException { + + List distributionPoints = getCRLDistributionPoints(certs[0]); + if (distributionPoints == null || distributionPoints.size() == 0) { + throw new GeneralSecurityException("Could not find any CRL distribution points in the certificate, unable to check the certificate revocation status using CRL/DP."); + } + for (String dp : distributionPoints) { + logger.tracef("CRL Distribution point: \"%s\"", dp); + checkRevocationStatusUsingCRL(certs, new CRLFileLoader(dp)); + } + } + + public CertificateValidator checkRevocationStatus() throws GeneralSecurityException { + if (!(_crlCheckingEnabled || _ocspEnabled)) { + return this; + } + if (_crlCheckingEnabled) { + if (!_crldpEnabled) { + checkRevocationStatusUsingCRL(_certChain, _crlLoader /*"crl.pem"*/); + } else { + checkRevocationStatusUsingCRLDistributionPoints(_certChain); + } + } + if (_ocspEnabled) { + checkRevocationUsingOCSP(_certChain); + } + return this; + } + + /** + * Configure Certificate validation + */ + public static class CertificateValidatorBuilder { + // A hand written DSL that walks through successive steps to configure + // instances of CertificateValidator type. The design is an adaption of + // the approach described in http://programmers.stackexchange.com/questions/252067/learning-to-write-dsls-utilities-for-unit-tests-and-am-worried-about-extensablit + + int _keyUsageBits; + List _extendedKeyUsage; + boolean _crlCheckingEnabled; + boolean _crldpEnabled; + CRLLoaderImpl _crlLoader; + boolean _ocspEnabled; + String _responderUri; + + public CertificateValidatorBuilder() { + _extendedKeyUsage = new LinkedList<>(); + _keyUsageBits = 0; + } + + public class KeyUsageValidationBuilder { + + CertificateValidatorBuilder _parent; + KeyUsageValidationBuilder(CertificateValidatorBuilder parent) { + _parent = parent; + } + + public KeyUsageValidationBuilder enableDigitalSignatureBit() { + _keyUsageBits |= 1 << KeyUsageBits.DIGITAL_SIGNATURE.getInt(); + return this; + } + public KeyUsageValidationBuilder enablecRLSignBit() { + _keyUsageBits |= 1 << KeyUsageBits.CRLSIGN.getInt(); + return this; + } + public KeyUsageValidationBuilder enableDataEncriphermentBit() { + _keyUsageBits |= 1 << KeyUsageBits.DATA_ENCIPHERMENT.getInt(); + return this; + } + public KeyUsageValidationBuilder enableDecipherOnlyBit() { + _keyUsageBits |= 1 << KeyUsageBits.DECIPHER_ONLY.getInt(); + return this; + } + public KeyUsageValidationBuilder enableEnciphermentOnlyBit() { + _keyUsageBits |= 1 << KeyUsageBits.ENCIPHERMENT_ONLY.getInt(); + return this; + } + public KeyUsageValidationBuilder enableKeyAgreementBit() { + _keyUsageBits |= 1 << KeyUsageBits.KEY_AGREEMENT.getInt(); + return this; + } + public KeyUsageValidationBuilder enableKeyEnciphermentBit() { + _keyUsageBits |= 1 << KeyUsageBits.KEY_ENCIPHERMENT.getInt(); + return this; + } + public KeyUsageValidationBuilder enableKeyCertSign() { + _keyUsageBits |= 1 << KeyUsageBits.KEYCERTSIGN.getInt(); + return this; + } + public KeyUsageValidationBuilder enableNonRepudiationBit() { + _keyUsageBits |= 1 << KeyUsageBits.NON_REPUDIATION.getInt(); + return this; + } + + public CertificateValidatorBuilder back() { + return _parent; + } + + CertificateValidatorBuilder parse(String keyUsage) { + if (keyUsage == null || keyUsage.trim().length() == 0) + return _parent; + + String[] strs = keyUsage.split("[,]"); + + for (String s : strs) { + try { + KeyUsageBits bit = KeyUsageBits.parse(s.trim()); + switch(bit) { + case CRLSIGN: enablecRLSignBit(); break; + case DATA_ENCIPHERMENT: enableDataEncriphermentBit(); break; + case DECIPHER_ONLY: enableDecipherOnlyBit(); break; + case DIGITAL_SIGNATURE: enableDigitalSignatureBit(); break; + case ENCIPHERMENT_ONLY: enableEnciphermentOnlyBit(); break; + case KEY_AGREEMENT: enableKeyAgreementBit(); break; + case KEY_ENCIPHERMENT: enableKeyEnciphermentBit(); break; + case KEYCERTSIGN: enableKeyCertSign(); break; + case NON_REPUDIATION: enableNonRepudiationBit(); break; + } + } + catch(IllegalArgumentException e) { + logger.warnf("Unable to parse key usage bit: \"%s\"", s); + } + catch(IndexOutOfBoundsException e) { + logger.warnf("Invalid key usage bit: \"%s\"", s); + } + } + + return _parent; + } + } + + public class ExtendedKeyUsageValidationBuilder { + + CertificateValidatorBuilder _parent; + protected ExtendedKeyUsageValidationBuilder(CertificateValidatorBuilder parent) { + _parent = parent; + } + + public CertificateValidatorBuilder parse(String extendedKeyUsage) { + if (extendedKeyUsage == null || extendedKeyUsage.trim().length() == 0) + return _parent; + + String[] strs = extendedKeyUsage.split("[,;:]]"); + for (String str : strs) { + _extendedKeyUsage.add(str.trim()); + } + return _parent; + } + } + + public class RevocationStatusCheckBuilder { + + CertificateValidatorBuilder _parent; + protected RevocationStatusCheckBuilder(CertificateValidatorBuilder parent) { + _parent = parent; + } + + public GotCRL cRLEnabled(boolean value) { + _crlCheckingEnabled = value; + return new GotCRL(); + } + + public class GotCRL { + public GotCRLDP cRLDPEnabled(boolean value) { + _crldpEnabled = value; + return new GotCRLDP(); + } + } + + public class GotCRLRelativePath { + public GotOCSP oCSPEnabled(boolean value) { + _ocspEnabled = value; + return new GotOCSP(); + } + } + public class GotCRLDP { + public GotCRLRelativePath cRLrelativePath(String value) { + if (value != null) + _crlLoader = new CRLFileLoader(value); + return new GotCRLRelativePath(); + } + + public GotCRLRelativePath cRLLoader(CRLLoaderImpl cRLLoader) { + if (cRLLoader != null) + _crlLoader = cRLLoader; + return new GotCRLRelativePath(); + } + } + + public class GotOCSP { + public CertificateValidatorBuilder oCSPResponderURI(String responderURI) { + _responderUri = responderURI; + return _parent; + } + } + } + + public KeyUsageValidationBuilder keyUsage() { + return new KeyUsageValidationBuilder(this); + } + + public ExtendedKeyUsageValidationBuilder extendedKeyUsage() { + return new ExtendedKeyUsageValidationBuilder(this); + } + + public RevocationStatusCheckBuilder revocation() { + return new RevocationStatusCheckBuilder(this); + } + + public CertificateValidator build(X509Certificate[] certs) { + if (_crlLoader == null) { + _crlLoader = new CRLFileLoader(""); + } + return new CertificateValidator(certs, _keyUsageBits, _extendedKeyUsage, + _crlCheckingEnabled, _crldpEnabled, _crlLoader, _ocspEnabled, new BouncyCastleOCSPChecker(_responderUri)); + } + } + + +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilAuthenticatorConfigModel.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilAuthenticatorConfigModel.java new file mode 100644 index 0000000..5e2b92d --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilAuthenticatorConfigModel.java @@ -0,0 +1,236 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import org.keycloak.models.AuthenticatorConfigModel; + +import static org.keycloak.authentication.authenticators.icpbrasil.AbstractICPBrasilClientCertificateAuthenticator.*; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ + +public class ICPBrasilAuthenticatorConfigModel extends AuthenticatorConfigModel { + + private static final long serialVersionUID = 1L; + + public enum IdentityMapperType { + USER_ATTRIBUTE(USER_ATTRIBUTE_MAPPER), + USERNAME_EMAIL(USERNAME_EMAIL_MAPPER); + + private String name; + IdentityMapperType(String name) { + this.name = name; + } + public String getName() { return this.name; } + static public IdentityMapperType parse(String name) throws IllegalArgumentException, IndexOutOfBoundsException { + if (name == null || name.trim().length() == 0) + throw new IllegalArgumentException("name"); + + for (IdentityMapperType value : IdentityMapperType.values()) { + if (value.getName().equalsIgnoreCase(name)) + return value; + } + throw new IndexOutOfBoundsException("name"); + } + } + + public enum MappingSourceType { + SERIALNUMBER(MAPPING_SOURCE_CERT_SERIALNUMBER), + ISSUERDN_CN(MAPPING_SOURCE_CERT_ISSUERDN_CN), + ISSUERDN_EMAIL(MAPPING_SOURCE_CERT_ISSUERDN_EMAIL), + ISSUERDN(MAPPING_SOURCE_CERT_ISSUERDN), + SUBJECTDN_CN(MAPPING_SOURCE_CERT_SUBJECTDN_CN), + SUBJECTDN_EMAIL(MAPPING_SOURCE_CERT_SUBJECTDN_EMAIL), + SUBJECTDN(MAPPING_SOURCE_CERT_SUBJECTDN), + SUBJECTCPF(MAPPING_SOURCE_CERT_SUBJECT_CPF), + SUBJECTCNPJ(MAPPING_SOURCE_CERT_SUBJECT_CNPJ), + SUBJECTCPFCNPJ(MAPPING_SOURCE_CERT_SUBJECT_CPF_CNPJ); + + private String name; + MappingSourceType(String name) { + this.name = name; + } + public String getName() { return this.name; } + static public MappingSourceType parse(String name) throws IllegalArgumentException, IndexOutOfBoundsException { + if (name == null || name.trim().length() == 0) + throw new IllegalArgumentException("name"); + + for (MappingSourceType value : MappingSourceType.values()) { + if (value.getName().equalsIgnoreCase(name)) + return value; + } + throw new IndexOutOfBoundsException("name"); + } + } + + public ICPBrasilAuthenticatorConfigModel(AuthenticatorConfigModel model) { + this.setAlias(model.getAlias()); + this.setId(model.getId()); + this.setConfig(model.getConfig()); + } + public ICPBrasilAuthenticatorConfigModel() { + + } + + public boolean getCRLEnabled() { + return Boolean.parseBoolean(getConfig().get(ENABLE_CRL)); + } + + public ICPBrasilAuthenticatorConfigModel setCRLEnabled(boolean value) { + getConfig().put(ENABLE_CRL, Boolean.toString(value)); + return this; + } + + public boolean getOCSPEnabled() { + return Boolean.parseBoolean(getConfig().get(ENABLE_OCSP)); + } + + public ICPBrasilAuthenticatorConfigModel setOCSPEnabled(boolean value) { + getConfig().put(ENABLE_OCSP, Boolean.toString(value)); + return this; + } + + public boolean getCRLDistributionPointEnabled() { + return Boolean.parseBoolean(getConfig().get(ENABLE_CRLDP)); + } + + public ICPBrasilAuthenticatorConfigModel setCRLDistributionPointEnabled(boolean value) { + getConfig().put(ENABLE_CRLDP, Boolean.toString(value)); + return this; + } + + public String getCRLRelativePath() { + return getConfig().getOrDefault(CRL_RELATIVE_PATH, null); + } + + public ICPBrasilAuthenticatorConfigModel setCRLRelativePath(String path) { + if (path != null) { + getConfig().put(CRL_RELATIVE_PATH, path); + } else { + getConfig().remove(CRL_RELATIVE_PATH); + } + return this; + } + + public String getOCSPResponder() { + return getConfig().getOrDefault(OCSPRESPONDER_URI, null); + } + + public ICPBrasilAuthenticatorConfigModel setOCSPResponder(String responderUri) { + if (responderUri != null) { + getConfig().put(OCSPRESPONDER_URI, responderUri); + } else { + getConfig().remove(OCSPRESPONDER_URI); + } + return this; + } + + public MappingSourceType getMappingSourceType() { + return MappingSourceType.parse(getConfig().getOrDefault(MAPPING_SOURCE_SELECTION, MAPPING_SOURCE_CERT_SUBJECTDN)); + } + + public ICPBrasilAuthenticatorConfigModel setMappingSourceType(MappingSourceType value) { + getConfig().put(MAPPING_SOURCE_SELECTION, value.getName()); + return this; + } + + public IdentityMapperType getUserIdentityMapperType() { + return IdentityMapperType.parse(getConfig().getOrDefault(USER_MAPPER_SELECTION, USERNAME_EMAIL_MAPPER)); + } + + public ICPBrasilAuthenticatorConfigModel setUserIdentityMapperType(IdentityMapperType value) { + getConfig().put(USER_MAPPER_SELECTION, value.getName()); + return this; + } + + public String getRegularExpression() { + return getConfig().getOrDefault(REGULAR_EXPRESSION,DEFAULT_MATCH_ALL_EXPRESSION); + } + + public ICPBrasilAuthenticatorConfigModel setRegularExpression(String value) { + if (value != null) { + getConfig().put(REGULAR_EXPRESSION, value); + } else { + getConfig().remove(REGULAR_EXPRESSION); + } + return this; + } + + public String getCustomAttributeName() { + return getConfig().getOrDefault(CUSTOM_ATTRIBUTE_NAME, DEFAULT_ATTRIBUTE_NAME); + } + + public ICPBrasilAuthenticatorConfigModel setCustomAttributeName(String value) { + if (value != null) { + getConfig().put(CUSTOM_ATTRIBUTE_NAME, value); + } else { + getConfig().remove(CUSTOM_ATTRIBUTE_NAME); + } + return this; + } + + public String getKeyUsage() { + return getConfig().getOrDefault(CERTIFICATE_KEY_USAGE, null); + } + + public ICPBrasilAuthenticatorConfigModel setKeyUsage(String value) { + if (value != null) { + getConfig().put(CERTIFICATE_KEY_USAGE, value); + } else { + getConfig().remove(CERTIFICATE_KEY_USAGE); + } + return this; + } + + public String getExtendedKeyUsage() { + return getConfig().getOrDefault(CERTIFICATE_EXTENDED_KEY_USAGE, null); + } + + public ICPBrasilAuthenticatorConfigModel setExtendedKeyUsage(String value) { + if (value != null) { + getConfig().put(CERTIFICATE_EXTENDED_KEY_USAGE, value); + } else { + getConfig().remove(CERTIFICATE_EXTENDED_KEY_USAGE); + } + return this; + } + + public boolean getConfirmationPageDisallowed() { + return Boolean.parseBoolean(getConfig().get(CONFIRMATION_PAGE_DISALLOWED)); + } + + public boolean getConfirmationPageAllowed() { + return !Boolean.parseBoolean(getConfig().get(CONFIRMATION_PAGE_DISALLOWED)); + } + + public ICPBrasilAuthenticatorConfigModel setConfirmationPageDisallowed(boolean value) { + getConfig().put(CONFIRMATION_PAGE_DISALLOWED, Boolean.toString(value)); + return this; + } + + public ICPBrasilAuthenticatorConfigModel setConfirmationPageAllowed(boolean value) { + getConfig().put(CONFIRMATION_PAGE_DISALLOWED, Boolean.toString(!value)); + return this; + } + +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilClientCertificateAuthenticator.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilClientCertificateAuthenticator.java new file mode 100644 index 0000000..2c855ff --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilClientCertificateAuthenticator.java @@ -0,0 +1,267 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import java.security.cert.X509Certificate; +import java.util.Enumeration; +import java.util.LinkedList; +import java.util.List; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.keycloak.services.ServicesLogger; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ +public class ICPBrasilClientCertificateAuthenticator extends AbstractICPBrasilClientCertificateAuthenticator { + + protected static ServicesLogger logger = ServicesLogger.LOGGER; + + @Override + public void close() { + + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + + try { + + dumpContainerAttributes(context); + + X509Certificate[] certs = getCertificateChain(context); + if (certs == null || certs.length == 0) { + // No x509 client cert, fall through and + // continue processing the rest of the authentication flow + logger.debug("[ICPBrasilClientCertificateAuthenticator:authenticate] x509 client certificate is not available for mutual SSL."); + context.attempted(); + return; + } + + ICPBrasilAuthenticatorConfigModel config = null; + if (context.getAuthenticatorConfig() != null && context.getAuthenticatorConfig().getConfig() != null) { + config = new ICPBrasilAuthenticatorConfigModel(context.getAuthenticatorConfig()); + } + if (config == null) { + logger.warn("[ICPBrasilClientCertificateAuthenticator:authenticate] x509 Client Certificate Authentication configuration is not available."); + context.challenge(createInfoResponse(context, "X509 client authentication has not been configured yet")); + context.attempted(); + return; + } + + // Validate X509 client certificate + try { + CertificateValidator.CertificateValidatorBuilder builder = certificateValidationParameters(config); + CertificateValidator validator = builder.build(certs); + validator.checkRevocationStatus() + .validateKeyUsage() + .validateExtendedKeyUsage(); + } catch(Exception e) { + logger.error(e.getMessage(), e); + // TODO use specific locale to load error messages + String errorMessage = "Certificate validation's failed."; + // TODO is calling form().setErrors enough to show errors on login screen? + context.challenge(createErrorResponse(context, certs[0].getSubjectDN().getName(), + errorMessage, e.getMessage())); + context.attempted(); + return; + } + + Object userIdentity = getUserIdentityExtractor(config).extractUserIdentity(certs); + if (userIdentity == null) { + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + logger.warnf("[ICPBrasilClientCertificateAuthenticator:authenticate] Unable to extract user identity from certificate."); + // TODO use specific locale to load error messages + String errorMessage = "Unable to extract user identity from specified certificate"; + // TODO is calling form().setErrors enough to show errors on login screen? + context.challenge(createErrorResponse(context, certs[0].getSubjectDN().getName(), errorMessage)); + context.attempted(); + return; + } + + UserModel user; + try { + context.getEvent().detail(Details.USERNAME, userIdentity.toString()); + context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, userIdentity.toString()); + user = getUserIdentityToModelMapper(config).find(context, userIdentity); + } + catch(ModelDuplicateException e) { + logger.modelDuplicateException(e); + String errorMessage = "X509 certificate authentication's failed."; + // TODO is calling form().setErrors enough to show errors on login screen? + context.challenge(createErrorResponse(context, certs[0].getSubjectDN().getName(), + errorMessage, e.getMessage())); + context.attempted(); + return; + } + + if (invalidUser(context, user)) { + // TODO use specific locale to load error messages + String errorMessage = "X509 certificate authentication's failed."; + // TODO is calling form().setErrors enough to show errors on login screen? + context.challenge(createErrorResponse(context, certs[0].getSubjectDN().getName(), + errorMessage, "Invalid user")); + context.attempted(); + return; + } + + if (!userEnabled(context, user)) { + // TODO use specific locale to load error messages + String errorMessage = "X509 certificate authentication's failed."; + // TODO is calling form().setErrors enough to show errors on login screen? + context.challenge(createErrorResponse(context, certs[0].getSubjectDN().getName(), + errorMessage, "User is disabled")); + context.attempted(); + return; + } + if (context.getRealm().isBruteForceProtected()) { + if (context.getProtector().isTemporarilyDisabled(context.getSession(), context.getRealm(), user)) { + context.getEvent().user(user); + context.getEvent().error(Errors.USER_TEMPORARILY_DISABLED); + // TODO use specific locale to load error messages + String errorMessage = "X509 certificate authentication's failed."; + // TODO is calling form().setErrors enough to show errors on login screen? + context.challenge(createErrorResponse(context, certs[0].getSubjectDN().getName(), + errorMessage, "User is temporarily disabled. Contact administrator.")); + context.attempted(); + return; + } + } + context.setUser(user); + + // Check whether to display the identity confirmation + if (!config.getConfirmationPageDisallowed()) { + // FIXME calling forceChallenge was the only way to display + // a form to let users either choose the user identity from certificate + // or to ignore it and proceed to a normal login screen. Attempting + // to call the method "challenge" results in a wrong/unexpected behavior. + // The question is whether calling "forceChallenge" here is ok from + // the design viewpoint? + context.forceChallenge(createSuccessResponse(context, certs[0].getSubjectDN().getName())); + // Do not set the flow status yet, we want to display a form to let users + // choose whether to accept the identity from certificate or to specify username/password explicitly + } + else { + // Bypass the confirmation page and log the user in + context.success(); + } + } + catch(Exception e) { + logger.errorf("[ICPBrasilClientCertificateAuthenticator:authenticate] Exception: %s", e.getMessage()); + context.attempted(); + } + } + + private Response createErrorResponse(AuthenticationFlowContext context, + String subjectDN, + String errorMessage, + String ... errorParameters) { + + return createResponse(context, subjectDN, false, errorMessage, errorParameters); + } + + private Response createSuccessResponse(AuthenticationFlowContext context, + String subjectDN) { + return createResponse(context, subjectDN, true, null, null); + } + + private Response createResponse(AuthenticationFlowContext context, + String subjectDN, + boolean isUserEnabled, + String errorMessage, + Object[] errorParameters) { + + LoginFormsProvider form = context.form(); + if (errorMessage != null && errorMessage.trim().length() > 0) { + List errors = new LinkedList<>(); + + errors.add(new FormMessage(errorMessage)); + if (errorParameters != null) { + + for (Object errorParameter : errorParameters) { + if (errorParameter == null) continue; + for (String part : errorParameter.toString().split("\n")) { + errors.add(new FormMessage(part)); + } + } + } + form.setErrors(errors); + } + + return form + .setAttribute("username", context.getUser() != null ? context.getUser().getUsername() : "unknown user") + .setAttribute("subjectDN", subjectDN) + .setAttribute("isUserEnabled", isUserEnabled) + .createForm("login-icpbrasil-info.ftl"); + } + + private void dumpContainerAttributes(AuthenticationFlowContext context) { + + Enumeration attributeNames = context.getHttpRequest().getAttributeNames(); + while(attributeNames.hasMoreElements()) { + String a = attributeNames.nextElement(); + logger.tracef("[ICPBrasilClientCertificateAuthenticator:dumpContainerAttributes] \"%s\"", a); + } + } + + private boolean userEnabled(AuthenticationFlowContext context, UserModel user) { + if (!user.isEnabled()) { + context.getEvent().user(user); + context.getEvent().error(Errors.USER_DISABLED); + return false; + } + return true; + } + + private boolean invalidUser(AuthenticationFlowContext context, UserModel user) { + if (user == null) { + context.getEvent().error(Errors.USER_NOT_FOUND); + return true; + } + return false; + } + + @Override + public void action(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + if (formData.containsKey("cancel")) { + context.clearUser(); + context.attempted(); + return; + } + if (context.getUser() != null) { + context.success(); + return; + } + context.attempted(); + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilClientCertificateAuthenticatorFactory.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilClientCertificateAuthenticatorFactory.java new file mode 100644 index 0000000..1accd54 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ICPBrasilClientCertificateAuthenticatorFactory.java @@ -0,0 +1,68 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ +public class ICPBrasilClientCertificateAuthenticatorFactory extends AbstractICPBrasilClientCertificateAuthenticatorFactory { + + public static final String PROVIDER_ID = "auth-icpbrasil-client-username-form"; + public static final ICPBrasilClientCertificateAuthenticator SINGLETON = + new ICPBrasilClientCertificateAuthenticator(); + + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + + @Override + public String getHelpText() { + return "Validates username and password from X509 client certificate received as a part of mutual SSL handshake."; + } + + @Override + public String getDisplayType() { + return "ICPBrasil/Validate Username Form"; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/UserIdentityExtractor.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/UserIdentityExtractor.java new file mode 100644 index 0000000..8ec1090 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/UserIdentityExtractor.java @@ -0,0 +1,264 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import freemarker.template.utility.NullArgumentException; +import org.bouncycastle.asn1.*; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.keycloak.services.ServicesLogger; + +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ + +public abstract class UserIdentityExtractor { + + private static final ServicesLogger logger = ServicesLogger.LOGGER; + + public abstract Object extractUserIdentity(X509Certificate[] certs); + + static class OrExtractor extends UserIdentityExtractor { + + UserIdentityExtractor extractor; + UserIdentityExtractor other; + OrExtractor(UserIdentityExtractor extractor, UserIdentityExtractor other) { + this.extractor = extractor; + this.other = other; + + if (this.extractor == null) + throw new NullArgumentException("extractor"); + if (this.other == null) + throw new NullArgumentException("other"); + } + + @Override + public Object extractUserIdentity(X509Certificate[] certs) { + Object result = this.extractor.extractUserIdentity(certs); + if (result == null) + result = this.other.extractUserIdentity(certs); + return result; + } + } + + static class ICPBrasilExtractor extends UserIdentityExtractor { + + protected static final String PESSOA_FISICA_OBJECTID = "2.16.76.1.3.1"; + + protected static final String PESSOA_JURIDICA_OBJECTID = "2.16.76.1.3.3"; + + + Function>> x509SubjectAlternativeNames; + ICPBrasilAuthenticatorConfigModel.MappingSourceType mappingSourceType; + + ICPBrasilExtractor(Function>> x509SubjectAlternativeNames, ICPBrasilAuthenticatorConfigModel.MappingSourceType mappingSourceType) { + this.x509SubjectAlternativeNames = x509SubjectAlternativeNames; + this.mappingSourceType = mappingSourceType; + } + + @Override + public Object extractUserIdentity(X509Certificate[] certs) { + + if (certs == null || certs.length == 0) + throw new IllegalArgumentException(); + + Collection> subjectAltNames = x509SubjectAlternativeNames.apply(certs); + + if (subjectAltNames != null) { + + for (final List sanItem : subjectAltNames) { + final ASN1Sequence seq = getAltnameSequence(sanItem); + + final String ICPBrasilString = getICPBrasilStringFromSequence(seq); + + if (ICPBrasilString != null) { + return ICPBrasilString; + } + + } + } + return null; + } + + private String getICPBrasilStringFromSequence(final ASN1Sequence seq) { + if (seq != null) { + // First in sequence is the object identifier, that we must check + final ASN1ObjectIdentifier id = ASN1ObjectIdentifier.getInstance(seq.getObjectAt(0)); + if (id != null) { + final boolean isPessoaFisica = PESSOA_FISICA_OBJECTID.equals(id.getId()); + final boolean isPessoaJuridica = PESSOA_JURIDICA_OBJECTID.equals(id.getId()); + + logger.debug("mappingSourceType: " + mappingSourceType + " -- isPessoaFisica: " + isPessoaFisica + " -- isPessoaJuridica: " + isPessoaJuridica); + if ((mappingSourceType.equals(ICPBrasilAuthenticatorConfigModel.MappingSourceType.SUBJECTCPFCNPJ) && (isPessoaFisica || isPessoaJuridica)) + || (mappingSourceType.equals(ICPBrasilAuthenticatorConfigModel.MappingSourceType.SUBJECTCPF) && isPessoaFisica) + || (mappingSourceType.equals(ICPBrasilAuthenticatorConfigModel.MappingSourceType.SUBJECTCNPJ) && isPessoaJuridica) ) { + final ASN1TaggedObject obj = (ASN1TaggedObject) seq.getObjectAt(1); + ASN1Primitive prim = obj.getObject(); + + // Due to bug in java cert.getSubjectAltName, it can be tagged an extra time + if (prim instanceof ASN1TaggedObject) { + prim = ASN1TaggedObject.getInstance(((ASN1TaggedObject) prim)).getObject(); + } + String content = null; + if (prim instanceof ASN1OctetString) { + content = new String(((ASN1OctetString) prim).getOctets()); + } else if (prim instanceof ASN1String) { + content = ((ASN1String) prim).getString(); + } else{ + return null; + } + + if (isPessoaFisica && content.length() >= 20) { + logger.debug("Returning CPF through Pessoa Fisica ObjectID content [" + content + "]"); + return content.substring(8, 19); + } + else if (isPessoaJuridica) { + logger.debug("Returning CNPJ through Pessoa Juridica ObjectID content [" + content + "]"); + return content; + } + return null; + } + } + } + return null; + } + + private ASN1Sequence getAltnameSequence(final List sanItem) { + //Should not be the case, but still, a extra "safety" check + if (sanItem.size() < 2) { + logger.error("Subject Alternative Name List does not contain at least two required elements. Returning null principal id..."); + } + final Integer itemType = (Integer) sanItem.get(0); + if (itemType == 0) { + final byte[] altName = (byte[]) sanItem.get(1); + return getAltnameSequence(altName); + } + return null; + } + + private ASN1Sequence getAltnameSequence(final byte[] sanValue) { + ASN1Primitive oct = null; + try (final ASN1InputStream input = new ASN1InputStream(sanValue)) { + oct = input.readObject(); + } catch (final IOException e) { + logger.error("Error on getting Alt Name as a DERSEquence: {}", e.getMessage(), e); + return null; + } + return ASN1Sequence.getInstance(oct); + } + } + + static class X500NameRDNExtractor extends UserIdentityExtractor { + + private ASN1ObjectIdentifier x500NameStyle; + Function x500Name; + X500NameRDNExtractor(ASN1ObjectIdentifier x500NameStyle, Function x500Name) { + this.x500NameStyle = x500NameStyle; + this.x500Name = x500Name; + } + + @Override + public Object extractUserIdentity(X509Certificate[] certs) { + + if (certs == null || certs.length == 0) + throw new IllegalArgumentException(); + + X500Name name = x500Name.apply(certs); + if (name != null) { + RDN[] rnds = name.getRDNs(x500NameStyle); + if (rnds != null && rnds.length > 0) { + RDN cn = rnds[0]; + return IETFUtils.valueToString(cn.getFirst().getValue()); + } + } + return null; + } + } + + static class PatternMatcher extends UserIdentityExtractor { + private final String _pattern; + private final Function _f; + PatternMatcher(String pattern, Function valueToMatch) { + _pattern = pattern; + _f = valueToMatch; + } + + @Override + public Object extractUserIdentity(X509Certificate[] certs) { + String value = _f.apply(certs); + + Pattern r = Pattern.compile(_pattern, Pattern.CASE_INSENSITIVE); + + Matcher m = r.matcher(value); + + if (!m.find()) { + logger.debugf("[PatternMatcher:extract] No matches were found for input \"%s\", pattern=\"%s\"", value, _pattern); + return null; + } + + if (m.groupCount() != 1) { + logger.debugf("[PatternMatcher:extract] Match produced more than a single group for input \"%s\", pattern=\"%s\"", value, _pattern); + return null; + } + + return m.group(1); + } + } + + static class OrBuilder { + UserIdentityExtractor extractor; + UserIdentityExtractor other; + OrBuilder(UserIdentityExtractor extractor) { + this.extractor = extractor; + } + + public UserIdentityExtractor or(UserIdentityExtractor other) { + return new OrExtractor(extractor, other); + } + } + + public static UserIdentityExtractor getPatternIdentityExtractor(String pattern, + Function func) { + return new PatternMatcher(pattern, func); + } + + public static UserIdentityExtractor getX500NameExtractor(ASN1ObjectIdentifier identifier, Function x500Name) { + return new X500NameRDNExtractor(identifier, x500Name); + } + + public static UserIdentityExtractor getICPBrasilExtractor(Function>> x509SubjectAlternativeNames, ICPBrasilAuthenticatorConfigModel.MappingSourceType mappingSourceType) { + return new ICPBrasilExtractor(x509SubjectAlternativeNames, mappingSourceType); + } + + public static OrBuilder either(UserIdentityExtractor extractor) { + return new OrBuilder(extractor); + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/UserIdentityToModelMapper.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/UserIdentityToModelMapper.java new file mode 100644 index 0000000..a1c1ff8 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/UserIdentityToModelMapper.java @@ -0,0 +1,73 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import java.util.List; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 7/30/2016 + */ + +public abstract class UserIdentityToModelMapper { + + public abstract UserModel find(AuthenticationFlowContext context, Object userIdentity) throws Exception; + + static class UsernameOrEmailMapper extends UserIdentityToModelMapper { + + @Override + public UserModel find(AuthenticationFlowContext context, Object userIdentity) throws Exception { + return KeycloakModelUtils.findUserByNameOrEmail(context.getSession(), context.getRealm(), userIdentity.toString().trim()); + } + } + + static class UserIdentityToCustomAttributeMapper extends UserIdentityToModelMapper { + + private String _customAttribute; + UserIdentityToCustomAttributeMapper(String customAttribute) { + _customAttribute = customAttribute; + } + + @Override + public UserModel find(AuthenticationFlowContext context, Object userIdentity) throws Exception { + KeycloakSession session = context.getSession(); + List users = session.users().searchForUserByUserAttribute(_customAttribute, userIdentity.toString(), context.getRealm()); + if (users != null && users.size() > 1) { + throw new ModelDuplicateException(); + } + return users != null && users.size() == 1 ? users.get(0) : null; + } + } + + public static UserIdentityToModelMapper getUsernameOrEmailMapper() { + return new UsernameOrEmailMapper(); + } + + public static UserIdentityToModelMapper getUserIdentityToCustomAttributeMapper(String attributeName) { + return new UserIdentityToCustomAttributeMapper(attributeName); + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ValidateICPBrasilCertificateUsername.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ValidateICPBrasilCertificateUsername.java new file mode 100644 index 0000000..87d9556 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ValidateICPBrasilCertificateUsername.java @@ -0,0 +1,143 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import java.security.cert.X509Certificate; + +import javax.ws.rs.core.Response; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.UserModel; +import org.keycloak.services.ServicesLogger; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 7/31/2016 + */ + +public class ValidateICPBrasilCertificateUsername extends AbstractICPBrasilClientCertificateDirectGrantAuthenticator { + + protected static ServicesLogger logger = ServicesLogger.LOGGER; + + @Override + public void authenticate(AuthenticationFlowContext context) { + + X509Certificate[] certs = getCertificateChain(context); + if (certs == null || certs.length == 0) { + logger.debug("[ValidateICPBrasilCertificateUsername:authenticate] x509 client certificate is not available for mutual SSL."); + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "X509 client certificate is missing."); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + + ICPBrasilAuthenticatorConfigModel config = null; + if (context.getAuthenticatorConfig() != null && context.getAuthenticatorConfig().getConfig() != null) { + config = new ICPBrasilAuthenticatorConfigModel(context.getAuthenticatorConfig()); + } + if (config == null) { + logger.warn("[ValidateICPBrasilCertificateUsername:authenticate] x509 Client Certificate Authentication configuration is not available."); + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Configuration is missing."); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + // Validate X509 client certificate + try { + CertificateValidator.CertificateValidatorBuilder builder = certificateValidationParameters(config); + CertificateValidator validator = builder.build(certs); + validator.checkRevocationStatus() + .validateKeyUsage() + .validateExtendedKeyUsage(); + } catch(Exception e) { + logger.error(e.getMessage(), e); + // TODO use specific locale to load error messages + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", e.getMessage()); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + + Object userIdentity = getUserIdentityExtractor(config).extractUserIdentity(certs); + if (userIdentity == null) { + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + logger.errorf("[ValidateICPBrasilCertificateUsername:authenticate] Unable to extract user identity from certificate."); + // TODO use specific locale to load error messages + String errorMessage = "Unable to extract user identity from specified certificate"; + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", errorMessage); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + UserModel user; + try { + context.getEvent().detail(Details.USERNAME, userIdentity.toString()); + context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, userIdentity.toString()); + user = getUserIdentityToModelMapper(config).find(context, userIdentity); + } + catch(ModelDuplicateException e) { + logger.modelDuplicateException(e); + String errorMessage = String.format("X509 certificate authentication's failed. Reason: \"%s\"", e.getMessage()); + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", errorMessage); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + catch(Exception e) { + logger.error(e.getMessage(), e); + String errorMessage = String.format("X509 certificate authentication's failed. Reason: \"%s\"", e.getMessage()); + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", errorMessage); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + if (user == null) { + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_grant", "Invalid user credentials"); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + if (!user.isEnabled()) { + context.getEvent().user(user); + context.getEvent().error(Errors.USER_DISABLED); + Response challengeResponse = errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_grant", "Account disabled"); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + if (context.getRealm().isBruteForceProtected()) { + if (context.getProtector().isTemporarilyDisabled(context.getSession(), context.getRealm(), user)) { + context.getEvent().user(user); + context.getEvent().error(Errors.USER_TEMPORARILY_DISABLED); + Response challengeResponse = errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_grant", "Account temporarily disabled"); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + } + context.setUser(user); + context.success(); + } + + @Override + public void action(AuthenticationFlowContext context) { + // Intentionally does nothing + } +} diff --git a/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ValidateICPBrasilCertificateUsernameFactory.java b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ValidateICPBrasilCertificateUsernameFactory.java new file mode 100644 index 0000000..57cab90 --- /dev/null +++ b/src/main/java/org/keycloak/authentication/authenticators/icpbrasil/ValidateICPBrasilCertificateUsernameFactory.java @@ -0,0 +1,63 @@ +/* + * Copyright 2016 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.authentication.authenticators.icpbrasil; + +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; + +/** + * @author Peter Nalyvayko + * @author Lucas Rogerio Caetano Ferreira + * @version $Revision: 1 $ + * @date 8/9/2017 + */ +public class ValidateICPBrasilCertificateUsernameFactory extends AbstractICPBrasilClientCertificateAuthenticatorFactory { + + public static final String PROVIDER_ID = "direct-grant-auth-icpbrasil-username"; + public static final ValidateICPBrasilCertificateUsername SINGLETON = new ValidateICPBrasilCertificateUsername(); + + @Override + public String getHelpText() { + return "Validates username and password from X509 client certificate received as a part of mutual SSL handshake."; + } + + @Override + public String getDisplayType() { + return "ICPBrasil/Validate Username"; + } + + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED + }; + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/main/resources/META-INF/jboss-deployment-structure.xml b/src/main/resources/META-INF/jboss-deployment-structure.xml new file mode 100644 index 0000000..66331d5 --- /dev/null +++ b/src/main/resources/META-INF/jboss-deployment-structure.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000..f8390a0 --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1,19 @@ +# +# Copyright 2016 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.keycloak.authentication.authenticators.icpbrasil.ICPBrasilClientCertificateAuthenticatorFactory +org.keycloak.authentication.authenticators.icpbrasil.ValidateICPBrasilCertificateUsernameFactory \ No newline at end of file