diff --git a/.github/img/foot-size-execution-config-tooltip.png b/.github/img/foot-size-execution-config-tooltip.png new file mode 100644 index 0000000..fd4e54b Binary files /dev/null and b/.github/img/foot-size-execution-config-tooltip.png differ diff --git a/.github/img/foot-size-form-config.png b/.github/img/foot-size-form-config.png new file mode 100644 index 0000000..0e08d76 Binary files /dev/null and b/.github/img/foot-size-form-config.png differ diff --git a/.github/img/foot-size-form-error.png b/.github/img/foot-size-form-error.png new file mode 100644 index 0000000..99ac9c2 Binary files /dev/null and b/.github/img/foot-size-form-error.png differ diff --git a/.github/img/foot-size-form.png b/.github/img/foot-size-form.png new file mode 100644 index 0000000..b5be397 Binary files /dev/null and b/.github/img/foot-size-form.png differ diff --git a/.github/img/new-authenticator-execution.png b/.github/img/new-authenticator-execution.png new file mode 100644 index 0000000..f9efc9b Binary files /dev/null and b/.github/img/new-authenticator-execution.png differ diff --git a/.github/workflows/automation-tests.yml b/.github/workflows/automation-tests.yml index b5b0628..44383b1 100644 --- a/.github/workflows/automation-tests.yml +++ b/.github/workflows/automation-tests.yml @@ -19,13 +19,13 @@ jobs: distribution: 'adopt' - name: Set version from git commit SHA - run: mvn -B -ntp versions:set -DgenerateBackupPoms=false -DnewVersion="${GITHUB_SHA::6}" + run: mvn -B -ntp versions:set -DgenerateBackupPoms=false -DnewVersion="${GITHUB_SHA::7}" - name: Build authenticator jar file run: mvn -B -ntp package - name: Build test docker container - run: docker-compose build --build-arg VERSION="${GITHUB_SHA::6}" + run: docker-compose build --build-arg VERSION="${GITHUB_SHA::7}" - name: Run automation tests run: mvn -B -ntp test -P automation-tests -D selenide.headless=true diff --git a/README.md b/README.md index d2704f9..c68a015 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Keycloak username password attribute +# Keycloak username password attribute authenticator [![automation tests](https://github.com/kilmajster/keycloak-username-password-attribute-authenticator/actions/workflows/automation-tests.yml/badge.svg)](https://github.com/kilmajster/keycloak-username-password-attribute-authenticator/actions/workflows/automation-tests.yml) ![Maven Central](https://img.shields.io/maven-central/v/io.github.kilmajster/keycloak-username-password-attribute-authenticator) ![Docker Image Version (latest by date)](https://img.shields.io/docker/v/kilmajster/keycloak-username-password-attribute-authenticator?label=docker%20hub) @@ -6,37 +6,119 @@ ![GitHub](https://img.shields.io/github/license/kilmajster/keycloak-username-password-attribute-authenticator) ## Description -Keycloak default login form with user attribute validation. +Keycloak default login form with additional user attribute validation. Example: -## How 2 use +

+ Login form preview +    + Form error message preview +

+ +## Usage To use this authenticator, it should be bundled together with Keycloak, here are two ways how to do that: -### using jar +### Deploying jar file +To deploy custom Keycloak extension it needs to be placed in `{$KEYCLOAK_PATH}/standalone/deployments/`. +Latest authenticator jar file can be downloaded from +[Github Releases](https://github.com/kilmajster/keycloak-username-password-attribute-authenticator/releases/latest) page or +[Maven Central Repository](https://mvnrepository.com/artifact/io.github.kilmajster/keycloak-username-password-attribute-authenticator/latest). +### Using Docker init container +If you want to use this authenticator in cloud environment, here is ready [init container](https://hub.docker.com/r/kilmajster/keycloak-username-password-attribute-authenticator). +Jar file is placed in `/opt/jboss/keycloak/standalone/deployments`, so same location as target one. +According to official Keycloak [example](https://github.com/codecentric/helm-charts/blob/master/charts/keycloak/README.md#providing-a-custom-theme), +Helm chart could look like following: +```yaml +extraInitContainers: | + - name: attribute-authenticator-provider + image: kilmajster/keycloak-username-password-attribute-authenticator:latest + imagePullPolicy: IfNotPresent + command: + - sh + args: + - -c + - | + echo "Copying attribute authenticator..." + cp -R /opt/jboss/keycloak/standalone/deployments/*.jar /attribute-authenticator + volumeMounts: + - name: attribute-authenticator + mountPath: /attribute-authenticator -### using docker init container -If you want to use this authenticator in some cloud envirenement, here is ready init container. Jar file is placed in `/opt/jboss/keycloak/standalone/deployments`, -so same location as target one. Possible -``` -kilmajster/keycloak-username-password-attribute-authenticator:latest -``` -#### example helm chart snippet +extraVolumeMounts: | + - name: attribute-authenticator + mountPath: /opt/jboss/keycloak/standalone/deployments + +extraVolumes: | + - name: attribute-authenticator + emptyDir: {} +``` ## Configuration -### Authenticator config -#### config via Keycloak UI / API +### Authentication configuration +

+ New authentication execution +

+ +

+ Form config tooltip +

+ +#### Minimal configuration - login_form_user_attribute -- login_form_generate_label -- login_form_attribute_label -#### config via env variables -- LOGIN_FORM_USER_ATTRIBUTE +

+ Authenticator configuration +

+ +#### Advanced configuration + - login_form_generate_label + - login_form_attribute_label + - login_form_error_message + - clear_user_on_attribute_validation_fail +##### config via Keycloak API +TODO +##### Configuration via environment variables - LOGIN_FORM_GENERATE_LABEL - LOGIN_FORM_ATTRIBUTE_LABEL +- LOGIN_FORM_ERROR_MESSAGE +- CLEAR_USER_ON_ATTRIBUTE_VALIDATION_FAIL -### Theme config +### Theme configuration #### Using bundled default keycloak theme + - choose theme `base-with-attribute` + - override authentication flow to `Browser with user attribute` + #### Extending own theme +```html +... +
+ + + +
+ + +
+ + + +
+ + +
+... +``` ------------------------------------- ### Development @@ -63,6 +145,5 @@ $ mvn test -P automation-tests ``` ##### running tests in docker ```shell -$ mvn test -P automation-tests -D headless=true -``` - +$ mvn test -P automation-tests -D selenide.headless=true +``` \ No newline at end of file diff --git a/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeForm.java b/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeForm.java index c0085b2..ac42d37 100644 --- a/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeForm.java +++ b/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeForm.java @@ -18,7 +18,6 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.validation.Validation; -import org.keycloak.theme.FreeMarkerUtil; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; @@ -76,14 +75,14 @@ public void authenticate(AuthenticationFlowContext context) { } private void configureUserAttributeLabel(AuthenticationFlowContext context) { - final String userAttributeLabel = configPropertyOf(context, USER_ATTRIBUTE_LABEL); + final String userAttributeLabel = configPropertyOf(context, LOGIN_FORM_ATTRIBUTE_LABEL); if (userAttributeLabel != null) { - context.form().setAttribute(USER_ATTRIBUTE_LABEL, userAttributeLabel); + context.form().setAttribute(LOGIN_FORM_ATTRIBUTE_LABEL, userAttributeLabel); } else { - final String userAttributeName = configPropertyOf(context, USER_ATTRIBUTE); + final String userAttributeName = configPropertyOf(context, LOGIN_FORM_USER_ATTRIBUTE); if (userAttributeName != null && !userAttributeName.isEmpty()) { - context.form().setAttribute(USER_ATTRIBUTE_LABEL, - isGeneratePropertyLabelEnabled(context) + context.form().setAttribute(LOGIN_FORM_ATTRIBUTE_LABEL, + isGenerateLabelEnabled(context) ? UserAttributeLabelGenerator.generateLabel(userAttributeName) : userAttributeName); } else { @@ -103,7 +102,7 @@ && validatePassword(context, user, formData) && validateUser(context, user, form } private boolean validateUserAttribute(AuthenticationFlowContext context, UserModel user, MultivaluedMap formData) { - final String providedAttribute = formData.getFirst(USER_ATTRIBUTE); + final String providedAttribute = formData.getFirst(LOGIN_FORM_USER_ATTRIBUTE); if (providedAttribute == null || providedAttribute.isEmpty()) { return invalidUserAttributeHandler(context, user, true); } @@ -116,7 +115,7 @@ private boolean validateUserAttribute(AuthenticationFlowContext context, UserMod } private boolean isProvidedAttributeValid(AuthenticationFlowContext context, UserModel user, String providedUserAttribute) { - String userAttributeName = context.getAuthenticatorConfig().getConfig().get(USER_ATTRIBUTE); + String userAttributeName = context.getAuthenticatorConfig().getConfig().get(LOGIN_FORM_USER_ATTRIBUTE); return user.getAttributeStream(userAttributeName) .anyMatch(attr -> attr.equals(providedUserAttribute)); } @@ -127,12 +126,12 @@ private boolean invalidUserAttributeHandler(AuthenticationFlowContext context, U context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); String errorText; - final String userAttributeErrorLabel = configPropertyOf(context, USER_ATTRIBUTE_ERROR_LABEL); - if (userAttributeErrorLabel != null && !userAttributeErrorLabel.isBlank()) { + final String configuredErrorMessage = configPropertyOf(context, LOGIN_FORM_ERROR_MESSAGE); + if (configuredErrorMessage != null && !configuredErrorMessage.isBlank()) { // error text directly from error label propertyModelException - errorText = userAttributeErrorLabel; + errorText = configuredErrorMessage; } else { - final String userAttributeLabel = configPropertyOf(context, USER_ATTRIBUTE_LABEL); + final String userAttributeLabel = configPropertyOf(context, LOGIN_FORM_ATTRIBUTE_LABEL); if (userAttributeLabel != null && !userAttributeLabel.isBlank()) { // get message from message.properties in case USER_ATTRIBUTE_LABEL is a message key final String message = context.form().getMessage(userAttributeLabel); @@ -140,9 +139,9 @@ private boolean invalidUserAttributeHandler(AuthenticationFlowContext context, U errorText = UserAttributeLabelGenerator.generateErrorText(message != null ? message : userAttributeLabel); } else { // user attribute label not provided so generating text based on attribute name - errorText = isGeneratePropertyLabelEnabled(context) // generate pretty error if property is not disabled - ? UserAttributeLabelGenerator.generateErrorText(configPropertyOf(context, USER_ATTRIBUTE)) - : "Invalid ".concat(configPropertyOf(context, USER_ATTRIBUTE)); // use raw attribute name + errorText = isGenerateLabelEnabled(context) // generate pretty error if property is not disabled + ? UserAttributeLabelGenerator.generateErrorText(configPropertyOf(context, LOGIN_FORM_USER_ATTRIBUTE)) + : "Invalid ".concat(configPropertyOf(context, LOGIN_FORM_USER_ATTRIBUTE)); // use raw attribute name } } @@ -150,7 +149,7 @@ private boolean invalidUserAttributeHandler(AuthenticationFlowContext context, U context.clearUser(); } - Response challengeResponse = challenge(context, errorText, USER_ATTRIBUTE); + Response challengeResponse = challenge(context, errorText, LOGIN_FORM_USER_ATTRIBUTE); if (isAttributeEmpty) { context.forceChallenge(challengeResponse); diff --git a/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeFormConfiguration.java b/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeFormConfiguration.java index c118d58..f7ba4d4 100644 --- a/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeFormConfiguration.java +++ b/src/main/java/io.github.kilmajster.keycloak/UsernamePasswordAttributeFormConfiguration.java @@ -9,48 +9,48 @@ public interface UsernamePasswordAttributeFormConfiguration { - String USER_ATTRIBUTE = "login_form_user_attribute"; - String GENERATE_FORM_LABEL = "login_form_generate_label"; - String USER_ATTRIBUTE_LABEL = "login_form_attribute_label"; - String USER_ATTRIBUTE_ERROR_LABEL = "login_form_attribute_error_label"; + String LOGIN_FORM_USER_ATTRIBUTE = "login_form_user_attribute"; + String LOGIN_FORM_GENERATE_LABEL = "login_form_generate_label"; + String LOGIN_FORM_ATTRIBUTE_LABEL = "login_form_attribute_label"; + String LOGIN_FORM_ERROR_MESSAGE = "login_form_error_message"; String CLEAR_USER_ON_ATTRIBUTE_VALIDATION_FAIL = "clear_user_on_attribute_validation_fail"; List PROPS = ProviderConfigurationBuilder.create() .property() - .name(USER_ATTRIBUTE) + .name(LOGIN_FORM_USER_ATTRIBUTE) .type(ProviderConfigProperty.STRING_TYPE) - .label("User attribute key name") + .label("User attribute") .helpText("TODO") .add() .property() - .name(GENERATE_FORM_LABEL) + .name(LOGIN_FORM_GENERATE_LABEL) .type(ProviderConfigProperty.BOOLEAN_TYPE) .label("Generate label") - .defaultValue(true) + .defaultValue("true") // only string value is accepted .helpText("TODO") .add() .property() .name(CLEAR_USER_ON_ATTRIBUTE_VALIDATION_FAIL) .type(ProviderConfigProperty.BOOLEAN_TYPE) - .label("Clear user on attribute validation fail") - .defaultValue(true) + .label("Clear user on validation fail") + .defaultValue("true") // only string value is accepted .helpText("TODO") .add() .property() - .name(USER_ATTRIBUTE_LABEL) + .name(LOGIN_FORM_ATTRIBUTE_LABEL) .type(ProviderConfigProperty.STRING_TYPE) .label("User attribute form label") .helpText("TODO") .add() .property() - .name(USER_ATTRIBUTE_ERROR_LABEL) + .name(LOGIN_FORM_ERROR_MESSAGE) .type(ProviderConfigProperty.STRING_TYPE) - .label("Message for user attribute validation error") + .label("Validation error message") .helpText("TODO") .add() @@ -61,8 +61,8 @@ static String configPropertyOf(final AuthenticationFlowContext context, final St .orElse(context.getAuthenticatorConfig().getConfig().get(configPropertyName)); } - static boolean isGeneratePropertyLabelEnabled(final AuthenticationFlowContext context) { - return Boolean.parseBoolean(configPropertyOf(context, GENERATE_FORM_LABEL)); + static boolean isGenerateLabelEnabled(final AuthenticationFlowContext context) { + return Boolean.parseBoolean(configPropertyOf(context, LOGIN_FORM_GENERATE_LABEL)); } static boolean isClearUserOnFailedAttributeValidationEnabled(final AuthenticationFlowContext context) { diff --git a/src/test/resources/cucumber/login-form-env-var-config.feature b/src/test/resources/cucumber/login-form-env-var-config.feature index 35a2cb5..0d9a237 100644 --- a/src/test/resources/cucumber/login-form-env-var-config.feature +++ b/src/test/resources/cucumber/login-form-env-var-config.feature @@ -3,17 +3,17 @@ Feature: Login form with user attribute and environment variable based configura Scenario: label and error message are generated from attribute name when environment variable configuration is empty Given keycloak is running with default setup When user navigates to login page - Then login form with attribute input labeled as "Test attr" should be shown + Then login form with attribute input labeled as "Foot size" should be shown When user log into account console with a valid credentials and user attribute equal "invalid-user-attribute" - Then form error with message "Invalid test attr." is present + Then form error with message "Invalid foot size." is present And attempted username is cleared Scenario: attempted username is not cleared when clearing is disabled via environment variable Given keycloak is running with CLEAR_USER_ON_ATTRIBUTE_VALIDATION_FAIL = false When user navigates to login page - Then login form with attribute input labeled as "Test attr" should be shown + Then login form with attribute input labeled as "Foot size" should be shown When user log into account console with a valid credentials and user attribute equal "invalid-user-attribute" - Then form error with message "Invalid test attr." is present + Then form error with message "Invalid foot size." is present And attempted username is set to "test" And restart login link is visible @@ -28,9 +28,9 @@ Feature: Login form with user attribute and environment variable based configura Scenario: label and error message are taken from attribute name without prettify Given keycloak is running with LOGIN_FORM_GENERATE_LABEL = false When user navigates to login page - Then login form with attribute input labeled as "test_attr" should be shown + Then login form with attribute input labeled as "foot_size" should be shown When user log into account console with a valid credentials and user attribute equal "invalid-user-attribute" - Then form error with message "Invalid test_attr" is present + Then form error with message "Invalid foot_size" is present And attempted username is cleared Scenario: label and error message are taken from environment variable diff --git a/src/test/resources/cucumber/login-form.feature b/src/test/resources/cucumber/login-form.feature index 5a294d8..ced7c68 100644 --- a/src/test/resources/cucumber/login-form.feature +++ b/src/test/resources/cucumber/login-form.feature @@ -5,6 +5,6 @@ Feature: Login form with user attribute When user goes to the account console page Then user should be not logged in When user clicks a sign in button - Then login form with attribute input labeled as "Test attr" should be shown - When user log into account console with a valid credentials and user attribute equal "test" + Then login form with attribute input labeled as "Foot size" should be shown + When user log into account console with a valid credentials and user attribute equal "46" Then user should be logged into account console \ No newline at end of file diff --git a/src/test/resources/dev-realm.json b/src/test/resources/dev-realm.json index d7a24d6..95fcc07 100644 --- a/src/test/resources/dev-realm.json +++ b/src/test/resources/dev-realm.json @@ -318,7 +318,7 @@ "totp" : false, "emailVerified" : false, "attributes" : { - "test_attr" : [ "test" ] + "foot_size" : [ "46" ] }, "credentials" : [ { "id" : "a0f53e85-a522-4ab4-85cb-95224dec8d35", @@ -1262,7 +1262,7 @@ "topLevel" : false, "builtIn" : false, "authenticationExecutions" : [ { - "authenticatorConfig" : "test_attr", + "authenticatorConfig" : "foot_size form", "authenticator" : "auth-username-password-attr-form", "authenticatorFlow" : false, "requirement" : "REQUIRED", @@ -1711,17 +1711,17 @@ } }, { "id" : "313d1d2f-08ac-4e5f-9eef-9899e0a64d3d", - "alias" : "test_attr", + "alias" : "foot_size form", "config" : { - "login_form_user_attribute" : "test_attr", + "login_form_user_attribute" : "foot_size", "clear_user_on_attribute_validation_fail" : "true", "login_form_generate_label": "true" } }, { "id" : "def2c287-f0b2-4b71-bf8e-9dee33bcbe6b", - "alias" : "test_attr auth", + "alias" : "foot_size auth", "config" : { - "user_attribute" : "test_attr" + "user_attribute" : "foot_size" } } ], "requiredActions" : [ {