Skip to content

Commit

Permalink
Move authenticator example to quickstarts
Browse files Browse the repository at this point in the history
Signed-off-by: stianst <[email protected]>

Add Secret Question authenticator back

To do that, implemented `getCredentialType` for `SecretQuestionRequiredAction`

Closes keycloak#553
  • Loading branch information
stianst authored and cornelpurcel committed Dec 1, 2024
1 parent 813687f commit a123bb1
Show file tree
Hide file tree
Showing 16 changed files with 943 additions and 0 deletions.
64 changes: 64 additions & 0 deletions extension/authenticator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
authenticator: Example custom authenticator
========================================================

Level: Beginner
Summary: Example custom authenticator
Target Product: <span>Keycloak</span>
Source: <https://github.com/keycloak/keycloak-quickstarts>


What is it?
-----------

This is an example of the Authenticator SPI implemented a custom authenticator. It allows the user to create a secret
question that is prompted when a user logs in to a new machine. The example does not aim to provide a realistic
authentication mechanisms and should not be leveraged in production.


System Requirements
-------------------

You need to have <span>Keycloak</span> running. It is recommended to use Keycloak 24 or later.

All you need to build this project is Java 17 (Java SDK 17) or later and Maven 3.6.3 or later.

Build and Deploy the Quickstart
-------------------------------

To build the provider, run the following maven command:

````
mvn -Pextension clean install -DskipTests=true
````

To install the provider, copy the target/authenticator-example.jar file to the `providers` directory of the server distribution.

Finally, start the server as follows:

kc.[sh|bat] start-dev

Enable the Provider for a Realm
-------------------------------
It is not recommended to try this out with the master realm, so either use an existing test realm or create a new one.
If you create a new realm you must also register at least one user in the realm.

To enable the custom authenticator the first step is to create a custom authentication flow where it can be registered.
Login to the <span>Keycloak</span> Admin Console and got to the Authentication tab. Select the `browser` flow and under
`Action` click on `Duplicate`. Leave the name and description as is, and click on `Duplicate` to create the custom
authentication flow.

Next step is to add the authenticator to the custom flow. On the row named `Copy of browser forms` click on the '+'
symbol and select `Add step`. Enter `secret` in the search box to find the custom authenticator. Select `Secret question`
and click `Add`. When added it will not be required initially, so find the row for `Secret Questions` and change
the requirement to `Required`.

Next step is to bind the custom flow as the default browser flow. Click on `Action` and select `Bind flow`,
choose the binding type `Browser flow` and click on `Save`.

Final step is to enable the required action that is used by users to enter the answer needed to login. Click on
`Authentication` then on `Required actions`. Next to `Secret Question` click on the toggle for `Enabled` to turn on
the required action.

Now everything is configured and ready to try out, and you can try to login to the realm. The first time the user logs
in the user will be prompted to provide an answer to a question. The user will be prompted the question again when
using a new machine or closing the browser.
66 changes: 66 additions & 0 deletions extension/authenticator/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!--
~ 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">
<parent>
<artifactId>keycloak-quickstart-parent</artifactId>
<groupId>org.keycloak.quickstarts</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<name>Keycloak Quickstart: Authenticator Example</name>
<description/>
<modelVersion>4.0.0</modelVersion>

<artifactId>authenticator-example</artifactId>
<packaging>jar</packaging>

<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${version.keycloak}</version>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
<finalName>authenticator-example</finalName>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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 jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.CredentialValidator;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.credential.CredentialProvider;
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 java.net.URI;
import java.util.Collections;
import java.util.List;

/**
* @author <a href="mailto:[email protected]">Bill Burke</a>
* @version $Revision: 1 $
*/
public class SecretQuestionAuthenticator implements Authenticator, CredentialValidator<SecretQuestionCredentialProvider> {

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) {
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();

NewCookie newCookie = new NewCookie.Builder("SECRET_QUESTION_ANSWERED").value("true")
.path(uri.getRawPath())
.maxAge(maxCookieAge)
.secure(false)
.build();
context.getSession().getContext().getHttpResponse().setCookieIfAbsent(newCookie);
}

protected boolean validateAnswer(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String secret = formData.getFirst("secret_answer");
String credentialId = formData.getFirst("credentialId");
if (credentialId == null || credentialId.isEmpty()) {
credentialId = getCredentialProvider(context.getSession())
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
}

UserCredentialModel input = new UserCredentialModel(credentialId, getType(context.getSession()), secret);
return getCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(), input);
}

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

@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return getCredentialProvider(session).isConfiguredFor(realm, user, getType(session));
}

@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
user.addRequiredAction(SecretQuestionRequiredAction.PROVIDER_ID);
}

public List<RequiredActionFactory> getRequiredActions(KeycloakSession session) {
return Collections.singletonList((SecretQuestionRequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, SecretQuestionRequiredAction.PROVIDER_ID));
}

@Override
public void close() {

}

@Override
public SecretQuestionCredentialProvider getCredentialProvider(KeycloakSession session) {
return (SecretQuestionCredentialProvider)session.getProvider(CredentialProvider.class, SecretQuestionCredentialProviderFactory.PROVIDER_ID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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:[email protected]">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.ALTERNATIVE,
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() {

}


}
Loading

0 comments on commit a123bb1

Please sign in to comment.