Commit 8b544375 authored by Jannik Hüls's avatar Jannik Hüls

added authenticator implementation example

parent c1558bff
# Example Custom Authenticator
1. First, Keycloak must be running.
2. Execute the follow. This will build the example and deploy it
$ mvn clean install wildfly:deploy
3. Copy the secret-question.ftl and secret-question-config.ftl files to the themes/base/login directory.
4. Login to admin console. Hit browser refresh if you are already logged in so that the new providers show up.
5. Go to the Authentication menu item and go to the Flow tab, you will be able to view the currently
defined flows. You cannot modify an built in flows, so, to add the Authenticator you
have to copy an existing flow or create your own. Copy the "Browser" flow.
6. In your copy, click the "Actions" menu item and "Add Execution". Pick Secret Question
7. Next you have to register the required action that you created. Click on the Required Actions tab in the Authenticaiton menu.
Click on the Register button and choose your new Required Action.
Your new required action should now be displayed and enabled in the required actions list.
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<name>Authenticator Example</name>
<description/>
<modelVersion>4.0.0</modelVersion>
<groupId>de.codecentric.keycloak</groupId>
<artifactId>authenticator-required-action-example</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<properties>
<version.compiler.maven.plugin>3.5.1</version.compiler.maven.plugin>
<version.hibernate.javax.persistence>1.0.0.Final</version.hibernate.javax.persistence>
<version.jboss-ejb-api>1.0.0.Final</version.jboss-ejb-api>
<arquillian-graphene.version>2.3.2</arquillian-graphene.version>
<arquillian-phantom.version>1.2.1.Final</arquillian-phantom.version>
<version.junit>4.12</version.junit>
<version.wildfly.maven.plugin>1.2.2.Final</version.wildfly.maven.plugin>
<keycloak.version>4.6.0.Final</keycloak.version>
<wildfly.version>11.0.0.Final</wildfly.version>
<jackson.version>2.9.5</jackson.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jboss.arquillian</groupId>
<artifactId>arquillian-bom</artifactId>
<version>1.4.0.Final</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.wildfly.bom</groupId>
<artifactId>wildfly-javaee7</artifactId>
<version>${wildfly.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.hibernate.javax.persistence</groupId>
<artifactId>hibernate-jpa-2.1-api</artifactId>
<version>${version.hibernate.javax.persistence}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.spec.javax.ejb</groupId>
<artifactId>jboss-ejb-api_3.2_spec</artifactId>
<version>${version.jboss-ejb-api}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.graphene</groupId>
<artifactId>graphene-webdriver</artifactId>
<version>${arquillian-graphene.version}</version>
<type>pom</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.extension</groupId>
<artifactId>arquillian-phantom-driver</artifactId>
<version>${arquillian-phantom.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.junit</groupId>
<artifactId>arquillian-junit-container</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${version.junit}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-test-helper</artifactId>
<version>${keycloak.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
<repositories>
<repository>
<id>maven.oracle.com</id>
<name>oracle-maven-repo</name>
<url>https://maven.oracle.com</url>
<layout>default</layout>
<releases>
<enabled>true</enabled>
<updatePolicy>always</updatePolicy>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>maven.oracle.com</id>
<name>oracle-maven-repo</name>
<url>https://maven.oracle.com</url>
<layout>default</layout>
<releases>
<enabled>true</enabled>
<updatePolicy>always</updatePolicy>
</releases>
</pluginRepository>
</pluginRepositories>
<build>
<finalName>authenticator-required-action-example</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${version.compiler.maven.plugin}</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<version>${version.wildfly.maven.plugin}</version>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
<plugin>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce-quickstart-realm-file-exist</id>
<phase>validate</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "title">
${msg("loginTitle",realm.name)}
<#elseif section = "header">
Setup Secret Question
<#elseif section = "form">
<form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="totp" class="${properties.kcLabelClass!}">What is your mom's first name?</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="totp" name="secret_answer" type="text" class="${properties.kcInputClass!}" />
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<div class="${properties.kcFormButtonsWrapperClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doSubmit")}"/>
</div>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>
\ No newline at end of file
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "title">
${msg("loginTitle",realm.name)}
<#elseif section = "header">
${msg("loginTitleHtml",realm.name)}
<#elseif section = "form">
<form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="totp" class="${properties.kcLabelClass!}">What is your mom's first name</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="totp" name="secret_answer" type="text" class="${properties.kcInputClass!}" />
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<div class="${properties.kcFormButtonsWrapperClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel" id="kc-cancel" type="submit" value="${msg("doCancel")}"/>
</div>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>
\ No newline at end of file
/*
* 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.
*/
package org.keycloak.examples.authenticator;
import org.jboss.resteasy.spi.HttpResponse;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.common.util.ServerCookie;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.net.URI;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class SecretQuestionAuthenticator implements Authenticator {
public static final String CREDENTIAL_TYPE = "secret_question";
protected boolean hasCookie(AuthenticationFlowContext context) {
Cookie cookie = context.getHttpRequest().getHttpHeaders().getCookies().get("SECRET_QUESTION_ANSWERED");
boolean result = cookie != null;
if (result) {
System.out.println("Bypassing secret question because cookie is set");
}
return result;
}
@Override
public void authenticate(AuthenticationFlowContext context) {
if (hasCookie(context)) {
context.success();
return;
}
Response challenge = context.form().createForm("secret-question.ftl");
context.challenge(challenge);
}
@Override
public void action(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
if (formData.containsKey("cancel")) {
context.cancelLogin();
return;
}
boolean validated = validateAnswer(context);
if (!validated) {
Response challenge = context.form()
.setError("badSecret")
.createForm("secret-question.ftl");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge);
return;
}
setCookie(context);
context.success();
}
protected void setCookie(AuthenticationFlowContext context) {
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
int maxCookieAge = 60 * 60 * 24 * 30; // 30 days
if (config != null) {
maxCookieAge = Integer.valueOf(config.getConfig().get("cookie.max.age"));
}
URI uri = context.getUriInfo().getBaseUriBuilder().path("realms").path(context.getRealm().getName()).build();
addCookie("SECRET_QUESTION_ANSWERED", "true",
uri.getRawPath(),
null, null,
maxCookieAge,
false, true);
}
public static void addCookie(String name, String value, String path, String domain, String comment, int maxAge, boolean secure, boolean httpOnly) {
HttpResponse response = ResteasyProviderFactory.getContextData(HttpResponse.class);
StringBuffer cookieBuf = new StringBuffer();
ServerCookie.appendCookieValue(cookieBuf, 1, name, value, path, domain, comment, maxAge, secure, httpOnly);
String cookie = cookieBuf.toString();
response.getOutputHeaders().add(HttpHeaders.SET_COOKIE, cookie);
}
protected boolean validateAnswer(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String secret = formData.getFirst("secret_answer");
UserCredentialModel input = new UserCredentialModel();
input.setType(SecretQuestionCredentialProvider.SECRET_QUESTION);
input.setValue(secret);
return context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(), input);
}
@Override
public boolean requiresUser() {
return true;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return session.userCredentialManager().isConfiguredFor(realm, user, SecretQuestionCredentialProvider.SECRET_QUESTION);
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
user.addRequiredAction(SecretQuestionRequiredAction.PROVIDER_ID);
}
@Override
public void close() {
}
}
/*
* 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.
*/
package org.keycloak.examples.authenticator;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.authentication.ConfigurableAuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.ArrayList;
import java.util.List;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {
public static final String PROVIDER_ID = "secret-question-authenticator";
private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return true;
}
@Override
public boolean isConfigurable() {
return true;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
static {
ProviderConfigProperty property;
property = new ProviderConfigProperty();
property.setName("cookie.max.age");
property.setLabel("Cookie Max Age");
property.setType(ProviderConfigProperty.STRING_TYPE);
property.setHelpText("Max age in seconds of the SECRET_QUESTION_COOKIE.");
configProperties.add(property);
}
@Override
public String getHelpText() {
return "A secret question that a user has to answer. i.e. What is your mother's maiden name.";
}
@Override
public String getDisplayType() {
return "Secret Question";
}
@Override
public String getReferenceCategory() {
return "Secret Question";
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}
/*
* 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.
*/
package org.keycloak.examples.authenticator;
import org.keycloak.common.util.Time;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputUpdater;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.cache.CachedUserModel;
import org.keycloak.models.cache.OnUserCache;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class SecretQuestionCredentialProvider implements CredentialProvider, CredentialInputValidator, CredentialInputUpdater, OnUserCache {
public static final String SECRET_QUESTION = "SECRET_QUESTION";
public static final String CACHE_KEY = SecretQuestionCredentialProvider.class.getName() + "." + SECRET_QUESTION;
protected KeycloakSession session;
public SecretQuestionCredentialProvider(KeycloakSession session) {
this.session = session;
}
public CredentialModel getSecret(RealmModel realm, UserModel user) {
CredentialModel secret = null;
if (user instanceof CachedUserModel) {
CachedUserModel cached = (CachedUserModel)user;
secret = (CredentialModel)cached.getCachedWith().get(CACHE_KEY);
} else {
List<CredentialModel> creds = session.userCredentialManager().getStoredCredentialsByType(realm, user, SECRET_QUESTION);
if (!creds.isEmpty()) secret = creds.get(0);
}
return secret;
}
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (!SECRET_QUESTION.equals(input.getType())) return false;
if (!(input instanceof UserCredentialModel)) return false;
UserCredentialModel credInput = (UserCredentialModel) input;
List<CredentialModel> creds = session.userCredentialManager().getStoredCredentialsByType(realm, user, SECRET_QUESTION);
if (creds.isEmpty()) {
CredentialModel secret = new CredentialModel();
secret.setType(SECRET_QUESTION);
secret.setValue(credInput.getValue());
secret.setCreatedDate(Time.currentTimeMillis());
session.userCredentialManager().createCredential(realm ,user, secret);
} else {
creds.get(0).setValue(credInput.getValue());
session.userCredentialManager().updateCredential(realm, user, creds.get(0));
}
session.userCache().evict(realm, user);
return true;
}
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
if (!SECRET_QUESTION.equals(credentialType)) return;
session.userCredentialManager().disableCredentialType(realm, user, credentialType);
session.userCache().evict(realm, user);
}
@Override
public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
if (!session.userCredentialManager().getStoredCredentialsByType(realm, user, SECRET_QUESTION).isEmpty()) {
Set<String> set = new HashSet<>();
set.add(SECRET_QUESTION);
return set;
} else {
return Collections.EMPTY_SET;
}
}
@Override
public boolean supportsCredentialType(String credentialType) {
return SECRET_QUESTION.equals(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
if (!SECRET_QUESTION.equals(credentialType)) return false;
return getSecret(realm, user) != null;
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!SECRET_QUESTION.equals(input.getType())) return false;
if (!(input instanceof UserCredentialModel)) return false;
String secret = getSecret(realm, user).getValue();
return secret != null && ((UserCredentialModel)input).getValue().equals(secret);
}
@Override
public void onCache(RealmModel realm, CachedUserModel user, UserModel delegate) {
List<CredentialModel> creds = session.userCredentialManager().getStoredCredentialsByType(realm, user, SECRET_QUESTION);
if (!creds.isEmpty()) user.getCachedWith().put(CACHE_KEY, creds.get(0));
}
}
/*
* 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.