Chuyển đến nội dung chính

Bài 17: Tùy chỉnh giao diện Keycloak - Themes

Theme types (Login, Account, Admin, Email, Welcome), cấu trúc theme directory, tạo custom theme, theme properties file, FreeMarker template engine, override template cụ thể, i18n messages, sử dụng CSS/JS custom, extending themes (parent), deploying themes (standalone, Docker, JAR/SPI), theme caching và workflow phát triển theme.

🔒 DevSecOps — Bài 17 Bài 17: Tùy chỉnh giao diện Keycloak - Themes

Keycloak từ Cơ bản đến Nâng cao

Phần 5: Themes, Events, Security và Vault

xdev.asia

1. Tổng quan về Keycloak Themes

Keycloak cho phép tùy chỉnh hoàn toàn giao diện người dùng thông qua hệ thống Themes. Mỗi theme kiểm soát giao diện của một phần cụ thể trong Keycloak, từ trang đăng nhập đến email thông báo.

1.1 Theme Types

Keycloak hỗ trợ 5 loại theme:

Theme TypeMô tảTrang áp dụng
LoginGiao diện đăng nhập, đăng ký, reset passwordLogin, Register, OTP, Reset Password, Error
AccountTrang quản lý tài khoản người dùngAccount Console (v3 React-based)
AdminGiao diện Admin ConsoleAdmin Console (React-based)
EmailTemplate email gửi đến userVerify Email, Reset Password, Event notifications
WelcomeTrang chào mừng mặc địnhLanding page khi truy cập root URL

1.2 Theme mặc định

Keycloak đi kèm các theme có sẵn:

  • keycloak — Theme mặc định cho Login, Account, Email
  • keycloak.v2 — Theme mới cho Login (Keycloak 24+), Account Console v3
  • base (deprecated) — Theme cơ sở, không nên sử dụng làm parent nữa

2. Cấu trúc thư mục Theme

Mỗi theme được tổ chức theo cấu trúc thư mục chuẩn:

themes/
└── my-custom-theme/
    ├── login/
    │   ├── theme.properties
    │   ├── resources/
    │   │   ├── css/
    │   │   │   └── styles.css
    │   │   ├── js/
    │   │   │   └── custom.js
    │   │   └── img/
    │   │       └── logo.png
    │   ├── templates/
    │   │   ├── login.ftl
    │   │   ├── register.ftl
    │   │   └── error.ftl
    │   └── messages/
    │       ├── messages_en.properties
    │       └── messages_vi.properties
    ├── account/
    │   ├── theme.properties
    │   └── ...
    └── email/
        ├── theme.properties
        ├── html/
        │   └── email-verification.ftl
        └── text/
            └── email-verification.ftl

2.1 Các thư mục chính

Thư mụcMục đích
resources/CSS, JavaScript, hình ảnh tĩnh
templates/FreeMarker template files (.ftl)
messages/File i18n cho đa ngôn ngữ

3. Theme Properties File

File theme.properties là file cấu hình trung tâm của mỗi theme type:

# themes/my-custom-theme/login/theme.properties

# Kế thừa từ theme keycloak (sử dụng tất cả template/resource chưa override)
parent=keycloak.v2

# Import resources từ theme khác (common resources)
import=common/keycloak

# CSS files (thêm custom CSS)
styles=css/login.css css/styles.css

# Locales hỗ trợ
locales=en,vi

# Script files
scripts=js/custom.js

# Cache control (cho production)
cacheControl=max-age=2592000

3.1 Các thuộc tính quan trọng

PropertyMô tảVí dụ
parentTheme cha để kế thừaparent=keycloak.v2
importImport resources từ theme khácimport=common/keycloak
stylesDanh sách CSS files (space-separated)styles=css/login.css css/custom.css
scriptsJavaScript filesscripts=js/app.js
localesNgôn ngữ hỗ trợlocales=en,vi,ja

4. FreeMarker Template Engine

Keycloak sử dụng Apache FreeMarker làm template engine cho Login và Email themes. FreeMarker cho phép tạo HTML động với các biến và logic.

4.1 Cú pháp cơ bản

<!-- Biến -->
${realm.displayName}
${url.loginAction}
${messagesPerField.printIfExists('username','has-error')}

<!-- Điều kiện -->
<#if realm.password>
  <!-- Form đăng nhập bằng password -->
</#if>

<#if social.providers??>
  <!-- Hiển thị social login buttons -->
  <#list social.providers as p>
    <a href="${p.loginUrl}">${p.displayName}</a>
  </#list>
</#if>

<!-- Macro -->
<#macro registrationLayout>
  <!-- Layout wrapper -->
</#macro>

4.2 Các biến có sẵn trong Login Theme

BiếnMô tả
realmThông tin realm (displayName, registrationAllowed, password, social...)
urlCác URL (loginAction, registrationUrl, loginResetCredentialsUrl...)
clientClient đang request login (name, clientId...)
loginDữ liệu form (username đã nhập...)
messageThông báo lỗi/thành công
messagesPerFieldValidation messages cho từng field
socialSocial login providers
localeLocale hiện tại và danh sách supported locales

5. Override Template cụ thể

Bạn chỉ cần override những template muốn thay đổi. Các template còn lại sẽ được kế thừa từ parent theme.

5.1 Override Login Page

<!-- themes/my-custom-theme/login/templates/login.ftl -->
<#import "template.ftl" as layout>

<@layout.registrationLayout
    displayMessage=!messagesPerField.existsError('username','password')
    displayInfo=realm.password && realm.registrationAllowed
    ; section>

    <#if section = "header">
        <div class="custom-header">
            <img src="${url.resourcesPath}/img/logo.png" alt="Logo" class="login-logo" />
            <h1>${msg("loginAccountTitle")}</h1>
        </div>
    <#elseif section = "form">
        <div class="custom-login-form">
            <form action="${url.loginAction}" method="post">
                <div class="form-group">
                    <label for="username">
                        <#if !realm.loginWithEmailAllowed>
                            ${msg("username")}
                        <#elseif !realm.registrationEmailAsUsername>
                            ${msg("usernameOrEmail")}
                        <#else>
                            ${msg("email")}
                        </#if>
                    </label>
                    <input id="username" name="username" type="text"
                           value="${(login.username!'')}"
                           class="form-control ${messagesPerField.printIfExists('username','has-error')}"
                           autofocus autocomplete="username" />
                    <#if messagesPerField.existsError('username')>
                        <span class="error-message">${messagesPerField.get('username')}</span>
                    </#if>
                </div>

                <div class="form-group">
                    <label for="password">${msg("password")}</label>
                    <input id="password" name="password" type="password"
                           class="form-control ${messagesPerField.printIfExists('password','has-error')}"
                           autocomplete="current-password" />
                    <#if messagesPerField.existsError('password')>
                        <span class="error-message">${messagesPerField.get('password')}</span>
                    </#if>
                </div>

                <div class="form-actions">
                    <#if realm.rememberMe && !usernameHidden??>
                        <label class="remember-me">
                            <input id="rememberMe" name="rememberMe" type="checkbox"
                                   <#if login.rememberMe??>checked</#if>>
                            ${msg("rememberMe")}
                        </label>
                    </#if>

                    <button type="submit" class="btn btn-primary">
                        ${msg("doLogIn")}
                    </button>
                </div>
            </form>

            <#if realm.password && realm.registrationAllowed>
                <div class="register-link">
                    ${msg("noAccount")}
                    <a href="${url.registrationUrl}">${msg("doRegister")}</a>
                </div>
            </#if>
        </div>
    </#if>
</@layout.registrationLayout>

5.2 Override Register Page

<!-- themes/my-custom-theme/login/templates/register.ftl -->
<#import "template.ftl" as layout>

<@layout.registrationLayout
    displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm')
    ; section>

    <#if section = "header">
        <h1>${msg("registerTitle")}</h1>
    <#elseif section = "form">
        <form action="${url.registrationAction}" method="post">
            <div class="form-group">
                <label for="firstName">${msg("firstName")}</label>
                <input id="firstName" name="firstName" type="text"
                       value="${(register.formData.firstName!'')}"
                       class="form-control" />
            </div>

            <div class="form-group">
                <label for="lastName">${msg("lastName")}</label>
                <input id="lastName" name="lastName" type="text"
                       value="${(register.formData.lastName!'')}"
                       class="form-control" />
            </div>

            <div class="form-group">
                <label for="email">${msg("email")}</label>
                <input id="email" name="email" type="email"
                       value="${(register.formData.email!'')}"
                       class="form-control" autocomplete="email" />
            </div>

            <button type="submit" class="btn btn-primary">
                ${msg("doRegister")}
            </button>

            <div class="back-to-login">
                <a href="${url.loginUrl}">${msg("backToLogin")}</a>
            </div>
        </form>
    </#if>
</@layout.registrationLayout>

5.3 Override Error Page

<!-- themes/my-custom-theme/login/templates/error.ftl -->
<#import "template.ftl" as layout>

<@layout.registrationLayout displayMessage=false; section>
    <#if section = "header">
        ${kcSanitize(msg("errorTitle"))?no_esc}
    <#elseif section = "form">
        <div class="error-container">
            <div class="alert alert-error">
                <span class="error-icon">⚠</span>
                ${kcSanitize(message.summary)?no_esc}
            </div>
            <#if skipLink??>
            <#else>
                <#if client?? && client.baseUrl?has_content>
                    <a href="${client.baseUrl}" class="btn btn-primary">
                        ${kcSanitize(msg("backToApplication"))?no_esc}
                    </a>
                </#if>
            </#if>
        </div>
    </#if>
</@layout.registrationLayout>

6. Custom CSS và JavaScript

6.1 Thêm Custom CSS

/* themes/my-custom-theme/login/resources/css/styles.css */

/* Override login page styles */
.login-pf body {
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
    font-family: 'Inter', -apple-system, sans-serif;
}

/* Custom login card */
#kc-login {
    background: rgba(255, 255, 255, 0.95);
    border-radius: 16px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
    padding: 40px;
    max-width: 440px;
    margin: 0 auto;
}

/* Logo */
.login-logo {
    max-width: 180px;
    margin: 0 auto 24px;
    display: block;
}

/* Form inputs */
.form-control {
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    padding: 12px 16px;
    font-size: 14px;
    transition: border-color 0.2s;
}

.form-control:focus {
    border-color: #0f3460;
    box-shadow: 0 0 0 3px rgba(15, 52, 96, 0.1);
    outline: none;
}

.form-control.has-error {
    border-color: #e74c3c;
}

/* Primary button */
.btn-primary {
    background: #0f3460;
    color: white;
    border: none;
    border-radius: 8px;
    padding: 12px 24px;
    font-size: 16px;
    font-weight: 600;
    width: 100%;
    cursor: pointer;
    transition: background 0.2s;
}

.btn-primary:hover {
    background: #1a4a7a;
}

/* Social login buttons */
.social-links {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-top: 16px;
}

6.2 Thêm Custom JavaScript

// themes/my-custom-theme/login/resources/js/custom.js

document.addEventListener('DOMContentLoaded', function() {
    // Password visibility toggle
    const passwordField = document.getElementById('password');
    if (passwordField) {
        const toggleBtn = document.createElement('button');
        toggleBtn.type = 'button';
        toggleBtn.className = 'password-toggle';
        toggleBtn.textContent = '👁';
        toggleBtn.addEventListener('click', function() {
            passwordField.type = passwordField.type === 'password' ? 'text' : 'password';
        });
        passwordField.parentNode.appendChild(toggleBtn);
    }

    // Form validation enhancement
    const form = document.querySelector('form');
    if (form) {
        form.addEventListener('submit', function(e) {
            const submitBtn = form.querySelector('button[type="submit"]');
            if (submitBtn) {
                submitBtn.disabled = true;
                submitBtn.textContent = 'Đang xử lý...';
            }
        });
    }
});

7. Internationalization (i18n)

Keycloak hỗ trợ đa ngôn ngữ thông qua file messages_{locale}.properties.

7.1 Tạo file tiếng Việt

# themes/my-custom-theme/login/messages/messages_vi.properties

# Login form
loginAccountTitle=Đăng nhập vào tài khoản
username=Tên đăng nhập
usernameOrEmail=Tên đăng nhập hoặc Email
password=Mật khẩu
rememberMe=Ghi nhớ đăng nhập
doLogIn=Đăng nhập
noAccount=Chưa có tài khoản?
doRegister=Đăng ký ngay

# Registration
registerTitle=Tạo tài khoản mới
firstName=Họ
lastName=Tên
email=Địa chỉ email
doRegister=Đăng ký

# Errors
invalidUserMessage=Tên đăng nhập hoặc mật khẩu không đúng.
accountDisabledMessage=Tài khoản đã bị vô hiệu hóa. Vui lòng liên hệ quản trị viên.

# Password reset
emailForgotTitle=Quên mật khẩu?
backToLogin=Quay lại đăng nhập

# Custom messages
welcomeMessage=Chào mừng bạn đến với hệ thống
companyName=Công ty của bạn

7.2 Override tin nhắn tiếng Anh

# themes/my-custom-theme/login/messages/messages_en.properties

# Override default messages
loginAccountTitle=Sign in to your account
welcomeMessage=Welcome to our platform
companyName=Your Company

# Custom messages cho branding
loginTitleHtml=<strong>Welcome</strong> to {0}

7.3 Sử dụng i18n trong template

<!-- Sử dụng message key -->
<h1>${msg("loginAccountTitle")}</h1>

<!-- Message với tham số -->
<p>${msg("loginTitleHtml", realm.displayName)}</p>

<!-- Kiểm tra message tồn tại -->
<#if msg("welcomeMessage")?has_content>
    <p class="welcome">${msg("welcomeMessage")}</p>
</#if>

<!-- Locale selector -->
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
    <div class="locale-selector">
        <#list locale.supported as l>
            <a href="${l.url}" class="${(l.label == locale.current)?then('active','')}">
                ${l.label}
            </a>
        </#list>
    </div>
</#if>

8. Extending Themes với Parent

Theme inheritance cho phép tạo theme mới dựa trên theme có sẵn, chỉ override những phần cần thay đổi.

8.1 Cách hoạt động

Thứ tự tìm kiếm resource/template:
1. Current theme (my-custom-theme/login/)
2. Parent theme (keycloak.v2/login/)
3. Parent của parent (nếu có)
4. Base resources (import)

8.2 Multi-level Inheritance

# themes/company-base/login/theme.properties
parent=keycloak.v2
styles=css/login.css css/company-base.css
locales=en,vi

# themes/company-product-a/login/theme.properties
parent=company-base
styles=css/login.css css/company-base.css css/product-a.css

8.3 Theme Variants

Từ Keycloak 24+, bạn có thể định nghĩa theme variants để có nhiều phiên bản khác nhau của cùng một theme:

# theme.properties
parent=keycloak.v2

# Variant definition (admin có thể chọn trong Realm Settings)
variant.light.styles=css/login.css css/light.css
variant.dark.styles=css/login.css css/dark.css

9. Deploying Themes

9.1 Standalone Deployment

Copy theme vào thư mục themes/ của Keycloak:

# Cấu trúc standalone
$KEYCLOAK_HOME/
├── themes/
│   └── my-custom-theme/
│       ├── login/
│       │   ├── theme.properties
│       │   ├── resources/
│       │   ├── templates/
│       │   └── messages/
│       └── email/
│           └── ...

9.2 Docker Deployment

# Dockerfile - Simple COPY
FROM quay.io/keycloak/keycloak:26.1 AS builder

# Copy custom theme
COPY themes/my-custom-theme /opt/keycloak/themes/my-custom-theme

# Build optimized Keycloak
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.1

COPY --from=builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

9.3 Docker Multi-stage Build với Node (Account v3)

# Dockerfile - Multi-stage build cho Account Console v3 customization
FROM node:20-alpine AS theme-builder

WORKDIR /theme
COPY account-theme/ .
RUN npm ci && npm run build

FROM quay.io/keycloak/keycloak:26.1 AS keycloak-builder

# Copy login/email themes
COPY themes/my-custom-theme /opt/keycloak/themes/my-custom-theme

# Copy built Account v3 theme
COPY --from=theme-builder /theme/dist /opt/keycloak/themes/my-custom-theme/account

RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.1
COPY --from=keycloak-builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

9.4 JAR/SPI Deployment

Đóng gói theme dưới dạng JAR file để deploy như SPI provider:

my-theme.jar
├── META-INF/
│   └── keycloak-themes.json
└── theme/
    └── my-custom-theme/
        ├── login/
        └── email/
// META-INF/keycloak-themes.json
{
    "themes": [
        {
            "name": "my-custom-theme",
            "types": ["login", "email"]
        }
    ]
}
# Deploy JAR
cp my-theme.jar $KEYCLOAK_HOME/providers/
$KEYCLOAK_HOME/bin/kc.sh build

10. Theme Development Workflow

10.1 Tắt cache khi phát triển

Mặc định Keycloak cache themes, cần tắt khi phát triển:

# Tắt theme caching cho development
bin/kc.sh start-dev \
  --spi-theme-static-max-age=-1 \
  --spi-theme-cache-themes=false \
  --spi-theme-cache-templates=false

10.2 Docker Compose cho Development

# docker-compose.dev.yml
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.1
    command:
      - start-dev
      - --spi-theme-static-max-age=-1
      - --spi-theme-cache-themes=false
      - --spi-theme-cache-templates=false
    volumes:
      # Mount theme directory cho hot-reload
      - ./themes/my-custom-theme:/opt/keycloak/themes/my-custom-theme
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
    ports:
      - "8080:8080"
    depends_on:
      - postgres

  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

10.3 Workflow phát triển

# 1. Khởi chạy dev environment
docker compose -f docker-compose.dev.yml up -d

# 2. Chỉnh sửa file theme (CSV, FTL, properties)
# Thay đổi sẽ tự động reflect (vì cache đã tắt)

# 3. Refresh browser để xem thay đổi
# Không cần restart Keycloak!

# 4. Áp dụng theme vào Realm
# Admin Console → Realm Settings → Themes → Login Theme: my-custom-theme

11. React Account Console v3 Customization

Account Console v3 sử dụng React + PatternFly. Để tùy chỉnh, bạn cần build custom React app.

11.1 Keycloakify (Recommended)

Sử dụng Keycloakify — framework chuyên dùng để tạo Keycloak themes với React:

# Tạo project mới với Keycloakify
npx create-keycloakify-project my-keycloak-theme
cd my-keycloak-theme

# Cấu trúc project
my-keycloak-theme/
├── src/
│   ├── login/          # Login theme pages
│   │   ├── KcPage.tsx
│   │   ├── pages/
│   │   │   ├── Login.tsx
│   │   │   ├── Register.tsx
│   │   │   └── ...
│   │   └── i18n.ts
│   └── account/        # Account theme pages
├── public/
│   └── ...
├── package.json
└── vite.config.ts

# Dev mode
npm run dev

# Build JAR
npm run build-keycloak-theme
# Output: dist_keycloak/my-keycloak-theme.jar

11.2 Keycloakify Login Page Example

// src/login/pages/Login.tsx
import { type PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";

export default function Login(props: PageProps<Extract<KcContext, { pageId: "login.ftl" }>, I18n>) {
    const { kcContext, i18n, Template } = props;
    const { url, realm, social, login } = kcContext;
    const { msg } = i18n;

    return (
        <Template kcContext={kcContext} i18n={i18n}>
            <div className="custom-login">
                <img src={`${url.resourcesPath}/img/logo.png`} alt="Logo" />
                <h1>{msg("loginAccountTitle")}</h1>

                <form action={url.loginAction} method="post">
                    <input
                        name="username"
                        defaultValue={login.username ?? ""}
                        placeholder={msg("usernameOrEmail")}
                    />
                    <input
                        name="password"
                        type="password"
                        placeholder={msg("password")}
                    />
                    <button type="submit">{msg("doLogIn")}</button>
                </form>

                {social?.providers && (
                    <div className="social-providers">
                        {social.providers.map(p => (
                            <a key={p.alias} href={p.loginUrl}>
                                {p.displayName}
                            </a>
                        ))}
                    </div>
                )}
            </div>
        </Template>
    );
}

12. Email Theme Customization

Email themes có hai phiên bản cho mỗi template: HTML và plain text.

<!-- themes/my-custom-theme/email/html/email-verification.ftl -->
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
    <div style="background: #0f3460; padding: 20px; text-align: center;">
        <img src="${url.resourcesPath}/img/logo-white.png" alt="Logo" style="max-width: 150px;" />
    </div>
    <div style="padding: 30px; background: #ffffff;">
        <h2>${msg("emailVerificationSubject")}</h2>
        <p>${msg("emailVerificationBody", linkExpiration, realmName)}</p>
        <a href="${link}" style="display: inline-block; background: #0f3460; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px;">
            ${msg("emailVerificationLinkText")}
        </a>
    </div>
    <div style="padding: 15px; text-align: center; color: #888; font-size: 12px;">
        <p>${msg("emailFooter")}</p>
    </div>
</body>
</html>
<!-- themes/my-custom-theme/email/text/email-verification.ftl -->
${msg("emailVerificationSubject")}

${msg("emailVerificationBodyPlainText", link, linkExpiration, realmName)}

13. Áp dụng Theme vào Realm

13.1 Qua Admin Console

  1. Đăng nhập Admin Console
  2. Chọn Realm cần cấu hình
  3. Vào Realm Settings → Themes
  4. Chọn theme cho từng type:
    • Login Theme: my-custom-theme
    • Account Theme: my-custom-theme
    • Admin Console Theme: (giữ mặc định)
    • Email Theme: my-custom-theme
  5. Nhấn Save

13.2 Qua REST API

# Cập nhật theme cho realm qua API
curl -X PUT "http://localhost:8080/admin/realms/my-realm" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "loginTheme": "my-custom-theme",
    "accountTheme": "my-custom-theme",
    "emailTheme": "my-custom-theme"
  }'

14. Best Practices

  • Luôn sử dụng parent theme — Kế thừa từ keycloak.v2 thay vì viết from scratch. Đảm bảo tương thích khi upgrade Keycloak.
  • Chỉ override những gì cần thay đổi — Không copy toàn bộ template. Chỉ tạo file cho template bạn muốn customize.
  • Tách theme theo client — Nếu có nhiều ứng dụng, có thể dùng theme khác nhau cho từng client (Realm Settings hoặc Client-level theme override).
  • Test trên nhiều trình duyệt — Đặc biệt login page phải hoạt động trên mobile.
  • Sử dụng i18n — Luôn dùng message keys thay vì hardcode text trong template.
  • Version control themes — Quản lý theme trong Git repo riêng hoặc cùng project.
  • CI/CD pipeline — Build theme JAR trong CI, test với Keycloak container, deploy tự động.