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 Type | Mô tả | Trang áp dụng |
|---|---|---|
| Login | Giao diện đăng nhập, đăng ký, reset password | Login, Register, OTP, Reset Password, Error |
| Account | Trang quản lý tài khoản người dùng | Account Console (v3 React-based) |
| Admin | Giao diện Admin Console | Admin Console (React-based) |
| Template email gửi đến user | Verify Email, Reset Password, Event notifications | |
| Welcome | Trang chào mừng mặc định | Landing 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ục | Mụ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
| Property | Mô tả | Ví dụ |
|---|---|---|
parent | Theme cha để kế thừa | parent=keycloak.v2 |
import | Import resources từ theme khác | import=common/keycloak |
styles | Danh sách CSS files (space-separated) | styles=css/login.css css/custom.css |
scripts | JavaScript files | scripts=js/app.js |
locales | Ngô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ến | Mô tả |
|---|---|
realm | Thông tin realm (displayName, registrationAllowed, password, social...) |
url | Các URL (loginAction, registrationUrl, loginResetCredentialsUrl...) |
client | Client đang request login (name, clientId...) |
login | Dữ liệu form (username đã nhập...) |
message | Thông báo lỗi/thành công |
messagesPerField | Validation messages cho từng field |
social | Social login providers |
locale | Locale 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
- Đăng nhập Admin Console
- Chọn Realm cần cấu hình
- Vào Realm Settings → Themes
- 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
- Login Theme:
- 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.v2thay 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.