Skip to content

Commit

Permalink
Added docs (#5)
Browse files Browse the repository at this point in the history
* simplified tests

* simplified tests

* Cucumber tests!

* tests polish

* bumped Keycloak version to 13.0.1

* renamed job

* renamed job

* test fix

* fixed attribute validation message

* version of build for automation tests is set from commit SHA

* [skip ci] Renamed some configuration descs / AT polish / added docs part & imgs

* [skip ci] Renamed some configuration descs / AT polish / added docs part & imgs

* [skip ci] Renamed some configuration descs / AT polish / added docs part & imgs

* updated readme & refactored automation tests accordingly to readme

* readme polish

* [skip ci] readme polish
  • Loading branch information
kilmajster authored Jun 6, 2021
1 parent ffa2e17 commit 7b20c79
Show file tree
Hide file tree
Showing 12 changed files with 148 additions and 68 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/img/foot-size-form-config.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/img/foot-size-form-error.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/img/foot-size-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/img/new-authenticator-execution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions .github/workflows/automation-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
123 changes: 102 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,42 +1,124 @@
# 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)
![Docker Pulls](https://img.shields.io/docker/pulls/kilmajster/keycloak-username-password-attribute-authenticator)
![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
<p align="center">
<img alt="Login form preview" src="/.github/img/foot-size-form.png" width="48%">
&nbsp; &nbsp;
<img alt="Form error message preview" src="/.github/img/foot-size-form-error.png" width="48%">
</p>

## 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
<p align="center">
<img src="/.github/img/new-authenticator-execution.png" alt="New authentication execution">
</p>
<p align="center">
<img src="/.github/img/foot-size-execution-config-tooltip.png" alt="Form config tooltip">
</p>
#### Minimal configuration
- login_form_user_attribute
- login_form_generate_label
- login_form_attribute_label
#### config via env variables
- LOGIN_FORM_USER_ATTRIBUTE
<p align="center">
<img src="/.github/img/foot-size-form-config.png" alt="Authenticator configuration">
</p>
#### 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
...
<div class="${properties.kcFormGroupClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
<input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
</div>
<!-- keycloak-user-attribute-authenticator custom code block start -->
<div class="${properties.kcFormGroupClass!}">
<label for="login_form_user_attribute" class="${properties.kcLabelClass!}">
<#if login_form_attribute_label??>
${msg(login_form_attribute_label)}
<#else>
${msg("login_form_attribute_label_default")}
</#if>
</label>
<input tabindex="3" id="login_form_user_attribute" class="${properties.kcInputClass!}"
name="login_form_user_attribute" type="text" autocomplete="off"
aria-invalid="<#if messagesPerField.existsError('login_form_user_attribute')>true</#if>"
/>
</div>
<!-- keycloak-user-attribute-authenticator custom code block end -->
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
...
```

-------------------------------------
### Development
Expand All @@ -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
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -103,7 +102,7 @@ && validatePassword(context, user, formData) && validateUser(context, user, form
}

private boolean validateUserAttribute(AuthenticationFlowContext context, UserModel user, MultivaluedMap<String, String> 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);
}
Expand All @@ -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));
}
Expand All @@ -127,30 +126,30 @@ 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);
// generating error message based on provided user attribute label
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
}
}

if (isClearUserOnFailedAttributeValidationEnabled(context)) {
context.clearUser();
}

Response challengeResponse = challenge(context, errorText, USER_ATTRIBUTE);
Response challengeResponse = challenge(context, errorText, LOGIN_FORM_USER_ATTRIBUTE);

if (isAttributeEmpty) {
context.forceChallenge(challengeResponse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderConfigProperty> 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()

Expand All @@ -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) {
Expand Down
12 changes: 6 additions & 6 deletions src/test/resources/cucumber/login-form-env-var-config.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/test/resources/cucumber/login-form.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 7b20c79

Please sign in to comment.