Skip to content

Commit

Permalink
Merge pull request #63 from ksummarized/00062_Keycloak_theme
Browse files Browse the repository at this point in the history
Closes #62 Styles for Keycloak login and register pages
  • Loading branch information
Sojusan authored Jul 30, 2024
2 parents 0d4e7de + b134732 commit 4bfe11b
Show file tree
Hide file tree
Showing 24 changed files with 1,053 additions and 3 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ KC_DB_URL=
KC_DB_USERNAME=
# The password of the user that keycloak will use to connect into the database
KC_DB_PASSWORD=
# The list of features that will be enabled
KC_FEATURES=account3,declarative-user-profile

# Keycloak app (realm-export.json)

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,6 @@ dist-ssr
# Environment files
.env
appsettings.Development.json

# Java
target/
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ services:
- "8080:8080"
volumes:
- ./keycloak/imports/realms/realm-export.json:/opt/keycloak/data/import/realm-export.json
- ./keycloak/imports/providers:/opt/keycloak/providers/
- ./keycloak/themes/ksummarized:/opt/keycloak/themes/ksummarized/
depends_on:
db:
condition: service_healthy
Expand Down
19 changes: 19 additions & 0 deletions keycloak/custom-functionalities/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Keycloak custom functionalities

This directory contains custom functionalities for Keycloak used in our project.

## How to create a functionality?

Just create a Java class with the functionality in our package `java/com/ksummarized/keycloak/`.

## How to import it into Keycloak?

After creating the functionality there is a need to add our new class into `resources/META-INF/services`. The name of the file will determine where our class will be available. As I created a custom `FormAction` then in order to make it available in form actions in Keycloak I need to add my new class into `FormActionFactory`.

After creating the necessary functionality a new `jar` file needs to be created. It could be achieved by running Maven command:

```cmd
mvn clean package
```

It will create a `target` folder with the new `jar` file. This file needs to be copied into `keycloak/imports/providers/` folder. This folder is linked as a docker volume, so the whole content is inserted to our Keycloak in the Docker.
40 changes: 40 additions & 0 deletions keycloak/custom-functionalities/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<project>
<modelVersion>4.0.0</modelVersion>

<groupId>com.ksummarized</groupId>
<artifactId>ksummarized</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<properties>
<keycloak.version>23.0.6</keycloak.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.version>3.9.6</maven.compiler.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.ksummarized.keycloak.authentication.forms;

import java.util.ArrayList;

import org.keycloak.authentication.forms.RegistrationPage;
import org.keycloak.authentication.forms.RegistrationPassword;
import org.keycloak.authentication.ValidationContext;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.policy.PasswordPolicyManagerProvider;
import org.keycloak.policy.PolicyError;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.validation.Validation;

import jakarta.ws.rs.core.MultivaluedMap;
import java.util.List;


public class RegistrationSimplePassword extends RegistrationPassword {
public static final String PROVIDER_ID = "registration-simple-password-action";

@Override
public String getHelpText() {
return "Validates only the password field. Use it to get rid of the confirm password field from register form. It also will store password in user's credential store.";
}

@Override
public String getDisplayType() {
return "Password Validation Without Confirm Field";
}

/**
* Gets the ID of the provider. This should be unique across all registered providers.
*
* @return the provider ID
*/
@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
List<FormMessage> errors = new ArrayList<>();
context.getEvent().detail(Details.REGISTER_METHOD, "form");
if (Validation.isBlank(formData.getFirst(RegistrationPage.FIELD_PASSWORD))) {
errors.add(new FormMessage(RegistrationPage.FIELD_PASSWORD, Messages.MISSING_PASSWORD));
}
if (formData.getFirst(RegistrationPage.FIELD_PASSWORD) != null) {
PolicyError err = context.getSession().getProvider(PasswordPolicyManagerProvider.class).validate(context.getRealm().isRegistrationEmailAsUsername() ? formData.getFirst(RegistrationPage.FIELD_EMAIL) : formData.getFirst(RegistrationPage.FIELD_USERNAME), formData.getFirst(RegistrationPage.FIELD_PASSWORD));
if (err != null)
errors.add(new FormMessage(RegistrationPage.FIELD_PASSWORD, err.getMessage(), err.getParameters()));
}

if (!errors.isEmpty()) {
context.error(Errors.INVALID_REGISTRATION);
formData.remove(RegistrationPage.FIELD_PASSWORD);
context.validationError(formData, errors);
} else {
context.success();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.ksummarized.keycloak.authentication.forms.RegistrationSimplePassword
Binary file added keycloak/imports/providers/ksummarized.jar
Binary file not shown.
72 changes: 69 additions & 3 deletions keycloak/imports/realms/realm-export.json
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@
"oidc.ciba.grant.enabled": "false",
"client.secret.creation.time": "1707855869",
"backchannel.logout.session.required": "true",
"login_theme": "keycloak",
"login_theme": "ksummarized",
"post.logout.redirect.uris": "http://localhost:8888/*",
"display.on.consent.screen": "false",
"oauth2.device.authorization.grant.enabled": "true",
Expand Down Expand Up @@ -1453,7 +1453,7 @@
"envelopeFrom": "",
"ssl": "false"
},
"loginTheme": "keycloak",
"loginTheme": "ksummarized",
"accountTheme": "keycloak.v2",
"adminTheme": "keycloak.v2",
"emailTheme": "keycloak",
Expand Down Expand Up @@ -1653,6 +1653,18 @@
}
}
],
"org.keycloak.userprofile.UserProfileProvider": [
{
"id": "0bc3b241-c6ac-4ea4-9e6c-fbb7f0f4b2c7",
"providerId": "declarative-user-profile",
"subComponents": {},
"config": {
"kc.user.profile.config": [
"{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]}}],\"groups\":[]}"
]
}
}
],
"org.keycloak.keys.KeyProvider": [
{
"id": "8cd70a0e-8231-4061-bdb0-c6f4a4a48024",
Expand Down Expand Up @@ -1848,6 +1860,59 @@
}
]
},
{
"id": "448b98c7-273f-4e6d-bac7-9c7e53adb8d7",
"alias": "Registration form",
"description": "registration form",
"providerId": "form-flow",
"topLevel": false,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "registration-user-creation",
"authenticatorFlow": false,
"requirement": "REQUIRED",
"priority": 20,
"autheticatorFlow": false,
"userSetupAllowed": false
},
{
"authenticator": "registration-simple-password-action",
"authenticatorFlow": false,
"requirement": "REQUIRED",
"priority": 60,
"autheticatorFlow": false,
"userSetupAllowed": false
},
{
"authenticator": "registration-recaptcha-action",
"authenticatorFlow": false,
"requirement": "DISABLED",
"priority": 63,
"autheticatorFlow": false,
"userSetupAllowed": false
}
]
},
{
"id": "d92d7ba8-d9d5-4797-a372-022f6db7f633",
"alias": "Registration without confirm password",
"description": "registration flow without confirm password",
"providerId": "basic-flow",
"topLevel": true,
"builtIn": false,
"authenticationExecutions": [
{
"authenticator": "registration-page-form",
"authenticatorFlow": true,
"requirement": "REQUIRED",
"priority": 10,
"autheticatorFlow": true,
"flowAlias": "Registration form",
"userSetupAllowed": false
}
]
},
{
"id": "3afce351-fa0c-47dc-b322-ed69ce072ce1",
"alias": "Reset - Conditional OTP",
Expand Down Expand Up @@ -2330,7 +2395,7 @@
}
],
"browserFlow": "browser",
"registrationFlow": "registration",
"registrationFlow": "Registration without confirm password",
"directGrantFlow": "direct grant",
"resetCredentialsFlow": "reset credentials",
"clientAuthenticationFlow": "clients",
Expand All @@ -2341,6 +2406,7 @@
"clientOfflineSessionMaxLifespan": "0",
"oauth2DevicePollingInterval": "5",
"clientSessionIdleTimeout": "0",
"userProfileEnabled": "true",
"clientOfflineSessionIdleTimeout": "0",
"cibaInterval": "5",
"realmReusableOtpCode": "false",
Expand Down
Empty file.
125 changes: 125 additions & 0 deletions keycloak/themes/ksummarized/login/login.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<#if section = "header">
<img id="ksummarized-logo" src="${url.resourcesPath}/img/KsummarizedLogo.png" alt="ksummarized logo" />
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<#if realm.password>
<form id="kc-form-login" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
<#if !usernameHidden??>
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>

<input tabindex="2" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="username"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>

<#if messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>

</div>
</#if>

<div class="${properties.kcFormGroupClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>

<div class="${properties.kcInputGroup!}">
<input tabindex="3" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="current-password"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
<button class="${properties.kcFormPasswordVisibilityButtonClass!}" type="button" aria-label="${msg('showPassword')}"
aria-controls="password" data-password-toggle tabindex="4"
data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}"
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
<i class="${properties.kcFormPasswordVisibilityIconShow!}" aria-hidden="true"></i>
</button>
</div>

<#if usernameHidden?? && messagesPerField.existsError('username','password')>
<span id="input-error" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}
</span>
</#if>

</div>

<#if realm.resetPasswordAllowed>
<div id="forgot-password">
<span>${msg("doForgotPassword")} <a class="ks-link" href=${url.loginResetCredentialsUrl}>Change it here</a></span>
</div>
</#if>

<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
<input tabindex="7" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>

<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameHidden??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
<#else>
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
</#if>
</label>
</div>
</#if>
</div>
</div>
</form>
</#if>
</div>
</div>
<script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>
<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration-container">
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="8" class="ks-link"
href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</div>
</#if>
<#elseif section = "socialProviders" >
<#if realm.password && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<span>${msg("identityProvideLoginLabel")}</span>

<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<li>
<a id="social-${p.alias}" class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" href="${p.loginUrl}">
<#switch p.alias>
<#case "google">
<img src="${url.resourcesPath}/img/GoogleLogo.svg" alt="${p.alias}" />
<#break>
<#case "facebook">
<img src="${url.resourcesPath}/img/FacebookLogo.svg" alt="${p.alias}" />
<#break>
<#case "twitter">
<img src="${url.resourcesPath}/img/XLogo.svg" alt="${p.alias}" />
<#break>
<#case "github">
<img src="${url.resourcesPath}/img/GithubLogo.svg" alt="${p.alias}" />
<#break>
<#default>
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
</#switch>
</a>
</li>
</#list>
</ul>
<div class="separator">or</div>
</div>
</#if>
</#if>

</@layout.registrationLayout>
Loading

0 comments on commit 4bfe11b

Please sign in to comment.