Создание своей (кастомной) страницы входа через сервис 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

Источник

Репозиторий с полностью реализованным функционалом лежит здесь.

Комментарии (1)


  1. ionicman
    07.09.2024 14:54

    Я правильно понимаю, что для странички ввода логина и пароля и сообщений о неудачном входе народ и вы в частности используете vue/react и ts?

    Можете обьяснить зачем?