Создание своей (кастомной) страницы входа через сервис keycloak - это отдельный вид искусства. Мало того, что в шаблонах тем используется нешироко распространённый язык шаблонизации .ftl
(FreeMarker), так разработчику ещё необходимо знать почти что все переменные окружения, которые нужны для работы с keycloak'ом.
Но когда перед разработчиком встаёт задача создать кастомную тему на привычных для команды технологиях этот "счастливчик" может начать рвать на себе волосы.
Именно такая задача встала передо мной и решение удалось найти чудом. Как раз из-за этого я до сих пор не являюсь точной копией персонажа Вина Дизеля из фильма "Ридик".
Репозиторий с реализованной кастомной темой здесь.
Вступление
Для начала стоит рассказать, что же вообще такое keycloak? Так как я являюсь фронтендером, уходить в детали реализации этого "космического корабля" не буду. Keycloak - это сервис, который позволяет весьма гибко управлять доступами клиентов в рамках продукта. При первом рассмотрении - сильно прокачанная CRM'ка.
На основе этого сервиса мы, команда разработки "Аналитического центра Нижнего Новгорода"(АНО АЦГ), решили создать единую точку входа (SSO) для всех сервисов компании. Наш фронт строится на Vue. Я как тимлид взялся за эту задачу.
К моему сожалению, ничего подходящего после 3-4х часов усиленного поиска найти не получилось. В самом конце и почти в полном отчаянии я просто начал искать репозитории на GitHub. Самым похожим решением был keycloakify, но он заточен под React. И вот я нашёл репозиторий, созданный в 2022 году, прекрасным португальским разработчиком. В `README.md` полностью описан метод как запустить этот проект (на версии keycloak'а 16.0.2). После недолгих танцев с бубном у меня получилось его запустить. Я разобрался как работает эта "химера" и хочу показать это Вам.
Разбор исходников
Для начала скачаем репозиторий и посмотрим как разработчик реализовал связь Vue и FreeMarker.
Сразу же бросается в глаза и дальше встаёт вопрос - а где же папка public
, файл index.html
? Может быть в src
:
Не видим и здесь.
Пойдём дальше и заглянем в файл webpack'а
Здесь написано достаточно много, поэтому остановимся только на важных моментах.
webpack
Мы можем заметить две переменные - THEME_NAME
и entries
const THEME_NAME = "openfinance";
const entries = [
"login",
"register",
"login-reset-password",
"login-update-profile",
"login-idp-link-confirm",
"login-idp-link-email",
];
Первая отвечает за название нашей будущей темы, вторая же содержит перечисление стейтов, с которыми мы познакомимся позже. Далее идёт сама конфигурация webpack'а.
Сразу оговорюсь - понятные многим поля расписывать не буду: devtool
, resolve
, mode
, watch
, module
(Документация).
entry
Это поле сообщает webpack'у, что точками входа будут являться файлы index.ts
в каждом стейте, которые должны храниться в src/views
(Документация).
output: {
path: path.resolve(__dirname, '..', 'themes', THEME_NAME, 'login'),
filename: 'resources/js/[name].js',
publicPath: '/'
},
Здесь мы уже начинаем понимать, что собранные компоненты будут находиться вне репозитория.
Плагины
В разделе плагинов пойдём по порядку и только по важному
HTMLWebpackPlugin - генерирует файлы .ftl
для каждого стейта на основе index.html
(по простому - переименование)
CopeWebpackPlugin - название и пример использования говорят сами за себя.
plugins: [
...entries.map(
entry =>
new HtmlWebpackPlugin({
inject: false,
template: path.resolve(
__dirname,
'src',
'views',
entry,
'index.ftl'
),
filename: `${entry}.ftl`,
minify: false
})
),
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, 'src', 'static'),
to: path.resolve(__dirname, '..', 'themes', THEME_NAME, 'login')
}
]
})
],
Данное копирование необходимо для использования общего шаблона для всех стейтов. Перейдём к нему.
template.ftl (обязательно посмотрите файл по ссылке)
В этом файле мы уже видим синтаксис FreeMarker'а. Я, разобравшись в этом коде, удивился изобретательности разработчика.
Для реализации доступа к переменным окружения keycloak'а и i18n тексту он создаёт глобальный скрипт, который интерпритируется как json
и в будущем пригодится в функционльаной части и шаблонах наших Vue компонент.
Самым последним тегом внутри <body>
мы видим некую конструкцию <#nested "scripts">
. Её можно сравнить со слотами во Vue. Сейчас разберёмся где оно применяется.
Работа с Vue3
Перейдём в папку views/login
.
Посмотрим на файл index.ftl
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<#if section = "scripts">
<script typo="module" src="${url.resourcesPath}/js/login.js"></script>
</#if>
</@layout.registrationLayout>
В 3ей строке мы видим условие, которое проверяет секцию на значение "scripts"
и при выполнении условия вставляет некий скрипт. Далее, в 4ой строке, видим этот самый скрипт, который является собранной версией приложения, отдельно собирающегося для каждого стейта.
В данной архитектуре мы работаем следующим образом. Каждый стейт является отдельной и независимой страницей. Так мы понимаем, что темы keycloak'а придерживаются MPA подхода. Если же переключиться на Vue, то разработчик, принимая во внимание всё вышеперечисленное, понимает, что его приложение будет работать в SSG MPA режиме.
Но для любителей и адептов SPA подхода скажу, что есть одна лазейка. Файлы index.ts
в каждом стейте фактически являются main.ts
файлом, который архитектурно принят в vite
и vue-cli
как точка входа в приложениях.
Работа с переменными keycloak'а
Ранее мы уже столкнулись с большим скриптом в tempalte.ftl
, как раз в котором регистрируются переменные keycloak'а в доступном для js'а формате.
<script id="environment" type="application/json">
{
"urls": {
"loginResetCredentials": "${url.loginResetCredentialsUrl}",
"login": "${url.loginUrl}",
"registration": "${url.registrationUrl}",
"loginAction": "${url.loginAction}",
"registrationAction": "${url.registrationAction}",
"resourcesPath": "${url.resourcesPath}"
},
"titles": {
"loginProfileTitle": "${msg("loginProfileTitle")}",
"loginAccountTitle": "${msg("loginAccountTitle")}",
"registerTitle": "${msg("registerTitle")}",
"emailForgotTitle": "${msg("emailForgotTitle")}",
"confirmLinkIdpTitle": "${msg("confirmLinkIdpTitle")}",
"emailLinkIdpTitle": "${msg("emailLinkIdpTitle", idpDisplayName)}"
},
"permissions": {
"usernameEditDisabled": <#if usernameEditDisabled??>true<#else>false</#if>,
"loginWithEmailAllowed": <#if realm.loginWithEmailAllowed>true<#else>false</#if>,
"registrationEmailAsUsername": <#if realm.registrationEmailAsUsername>true<#else>false</#if>,
"rememberMe": <#if realm.rememberMe>true<#else>false</#if>,
"resetPasswordAllowed": <#if realm.resetPasswordAllowed>true<#else>false</#if>,
"password": <#if realm.password>true<#else>false</#if>,
"registrationAllowed": <#if realm.registrationAllowed>true<#else>false</#if>,
"registrationDisabled": <#if registrationDisabled??>true<#else>false</#if>,
"passwordRequired": <#if passwordRequired??>true<#else>false</#if>
},
"labels": {
"firstName": "${msg("firstName")}",
"lastName": "${msg("lastName")}",
"username": "${msg("username")}",
"usernameOrEmail": "${msg("usernameOrEmail")}",
"email": "${msg("email")}",
"password": "${msg("password")}",
"passwordConfirm": "${msg("passwordConfirm")}",
"rememberMe": "${msg("rememberMe")}",
"doForgotPassword": "${msg("doForgotPassword")}",
"doLogIn": "${msg("doLogIn")}",
"doSubmit": "${msg("doSubmit")}",
"noAccount": "${msg("noAccount")}",
"doRegister": "${msg("doRegister")}",
"backToLogin": "${kcSanitize(msg("backToLogin"))?no_esc}",
"confirmLinkIdpContinue": "${msg("confirmLinkIdpContinue")}",
"doClickHere": "${msg("doClickHere")}"
},
"forms": {
"loginUsername": "${(login.username!'')}",
"loginRememberMe": <#if login.rememberMe??>true<#else>false</#if>,
"selectedCredential": "${(auth.selectedCredential!'')}",
"registerFirstName": <#if register??>"${(register.formData.firstName!'')}"<#else>""</#if>,
"registerLastName": <#if register??>"${(register.formData.lastName!'')}"<#else>""</#if>,
"registerEmail": <#if register??>"${(register.formData.email!'')}"<#else>""</#if>,
"registerUsername": <#if register??>"${(register.formData.username!'')}"<#else>""</#if>
},
"user": {
"username": <#if user??>"${(user.username!'')}"<#else>""</#if>,
"email": <#if user??>"${(user.email!'')}"<#else>""</#if>,
"firstName": <#if user??>"${(user.firstName!'')}"<#else>""</#if>,
"lastName": <#if user??>"${(user.lastName!'')}"<#else>""</#if>
},
"validations": {
"firstName": <#if messagesPerField.existsError('firstName')>"${kcSanitize(messagesPerField.get('firstName'))?no_esc}"<#else>""</#if>,
"lastName": <#if messagesPerField.existsError('lastName')>"${kcSanitize(messagesPerField.get('lastName'))?no_esc}"<#else>""</#if>,
"email": <#if messagesPerField.existsError('email')>"${kcSanitize(messagesPerField.get('email'))?no_esc}"<#else>""</#if>,
"usernameOrPassword": <#if messagesPerField.existsError('username','password')>"${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc}"<#else>""</#if>,
"username": <#if messagesPerField.existsError('username')>"${kcSanitize(messagesPerField.get('username'))?no_esc}"<#else>""</#if>,
"password": <#if messagesPerField.existsError('password')>"${kcSanitize(messagesPerField.get('password'))?no_esc}"<#else>""</#if>,
"passwordConfirm": <#if messagesPerField.existsError('password-confirm')>"${kcSanitize(messagesPerField.get('password-confirm'))?no_esc}"<#else>""</#if>
},
"message": {
"type": <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>"${message.type}"<#else>""</#if>,
"sumary": <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>"${kcSanitize(message.summary)?no_esc}"<#else>""</#if>
},
"social": [
<#if realm.password && social.providers??>
<#list social.providers as p>
{
"alias": "${p.alias}",
"displayName": "${p.displayName!}",
"loginUrl": "${p.loginUrl}"
}<#sep>, </#sep>
</#list>
</#if>
]
}
</script>
Этот объект не зря имеет атрибут id.
Для начала снова заглянем в src/views/login/index.ts
.
import '~/scss/index.scss'
import { createApp } from 'vue'
import index from './index.vue'
const environment = document.querySelector('#environment')
if (environment) {
const app = createApp(index)
app.provide<Environment>('environment', JSON.parse(String(environment.textContent)))
app.mount('#app')
}
Видим, что объект из вешеуказанного скрипта забирается и прокидывается во все приложение ниже (Provide/Inject).
Так мы сможем получать этот объект в любом месте нашего Vue приложения.
Обратимся к папке src/hooks
.
index.ts
полностью импортирует login.ts
, поэтому сразу обратимся к нему
login.ts (обязательно посмотрите файл)
Здесь мы уже видим использование прокинутой provide'ом переменной env
.
Единственная экспортируемая функция возвращает нужные нам поля этого объекта и ещё реализует некоторые функции.
Именно через эту функцию в будущем мы будем работать из Vue с keycloak'ом.
Сухой остаток
Подытоживая эту часть, мы понимает что:
-
Файл
template.ftl
в связке сindex.ftl
стейта являются аналогомindex.html
в классическом подходе к Vue. -
Файл
index.ts
является точкой входа в отдельное Vue приложение отдельного стейта в рамках keycloak'а. -
Если разработчик захочет, то в это Vue приложение можно добавить и
Router
, и стейт-менеджер (Pinia
) и это никак не отразится на работе с keycloak'ом. -
Для каждого стейта собирается своё приложение. Так можно считать
index.vue
файл заApp.vue
. Применяются эти приложения в стейтах через импорт скрипта, который является собранной версией приложения Vue.
Необходимые доработки
Из репозитория можно увидеть, что никаких шрифтов и картинок нет. Давайте же добавим их и не только.
Для начала необходимо вспомнить как работает наш webpack
. Мы используем CopyWebpackPlugin
для копирования папки src/static
в саму папку стейта. JavaScript забирается в стейте из папку resources
, соответственно можно положить шрифты, изображения и какие-нибудь статичные css стили туда же. Создадим новый паттерн для копирование в webpack'е.
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, "src", "static"),
to: path.resolve(__dirname, "..", "themes", THEME_NAME, "login"),
},
// Копирование глобальных ресурсов
{
from: path.resolve(__dirname, "src", "resources"),
to: path.resolve(
__dirname,
"..",
"themes",
THEME_NAME,
"login",
"resources",
),
},
// Копирование ресурсов (изображений), которые будут расположены в папке конкретного стейта
...entries
.filter((entry) => fs.existsSync(`${__dirname}/src/views/${entry}/img`))
.map((entry) => {
return {
from: path.resolve(__dirname, "src", "views", entry, "img"),
to: path.resolve(
__dirname,
"..",
"themes",
THEME_NAME,
"login",
"resources",
"img",
),
};
}),
],
}),
Чтобы получить доступ к этим фотографиям и шрифтам нам потребуется немного доработать главный шаблон и файл хуков.
Вернёмся к файлу src/hooks/login.ts
.
Добавим функцию:
const getImage = (url: string) => {
return env.urls.resourcesPath + "/img" + url;
}
Как раз с её помощью и будем получать изображения.
Теперь перейдём к src/static/template.ftl
.
Добавим тег <style>
в head файла:
<style>
@font-face {
font-family: "Roboto-Bold";
src: url("${url.resourcesPath}/fonts/Roboto-Bold.woff2");
}
@font-face {
font-family: "Roboto-Medium";
src: url("${url.resourcesPath}/fonts/Roboto-Medium.woff2");
}
@font-face {
font-family: "Roboto-Regular";
src: url("${url.resourcesPath}/fonts/Roboto-Regular.woff2");
}
</style>
Регистрация favicon'а и дефолтных css стилей:
<link rel="icon" href="${url.resourcesPath}/img/Logo.svg">
<link rel="stylesheet" href="${url.resourcesPath}/css/default.css">
Дополнительные доработки
У нас в компании принята практика создания папки компонент, которые можно будет использовать во всём приложении без их прямого импорта. Такие компоненты в основном являются атамарными компонентами UI-kit'а проекта, поэтому папка так и называется - UI
. Эта папка лежит в src/components
.
index.ts
import MyButton from "./MyButton.vue";
import MyInput from "./MyInput.vue";
import MyCheckbox from "./MyCheckbox.vue";
import LineWithText from "./LineWithText.vue";
const UIStore = [
MyButton, MyInput, MyCheckbox, LineWithText,
];
export default UIStore
Дальше блок таких компонент должен быть зарегистрирован во Vue приложении.
src/views/login/index.ts
import { Environment } from "@doc-types/environment";
import { createApp } from "vue";
import index from "./index.vue";
// Импорт модуля UI компонент
import UIStore from "@components/UI";
const environment = document.querySelector("#environment") as HTMLElement;
const app = createApp(index);
app.provide<Environment>("environment", JSON.parse(String(environment.textContent)));
// Регистрация компонент на уровне приложения
UIStore.forEach((component) => {
// @ts-ignore
app.component(component.__name ?? component.name, component);
});
app.mount("#app");
Определение своих alias'ов
webpack.config.js
resolve: {
extensions: [".ts", ".tsx", ".js", ".vue", ".json", ".scss"],
alias: {
"@components": path.resolve(__dirname, "src/components"),
"@": path.resolve(__dirname, "src"),
},
},
tsconfig.json
"paths": {
"@components/*": ["src/components/*"],
"@/*": ["src/*"],
},
Глобальные стили
Также в нашей компании всегда есть набор глобальных стилей, которые прописываем в index.scss
. Необходимо сказать webpack'у, что необходимо в стили каждой компоненты импортировать глобальные стили.
Для этого необходимо доработать применение модуля загрузки sass'а.
{
test: /.(scss|css)$/,
use: [
"style-loader",
"css-loader",
{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: { autoprefixer: {} },
},
},
},
// Доработка применения модуля "sass-loader"
{
loader: "sass-loader",
options: {
additionalData: `@import "@/scss/index.scss";`,
},
},
],
},
Запуск проекта
Если вы добрались до самого конца и всё ещё не "клюёте носом", тогда последний шаг для нашего проекта это его запуск.
Всего лишь одна команда:
docker-compose -f docker-compose.yml up --build -d
Источник
Репозиторий с полностью реализованным функционалом лежит здесь.
Автор: aleks_andr_19