Keycloak登录页面二次开发
什么是Keycloak
Keycloak是一个开源的身份和访问管理解决方案,它提供了单点登录(SSO)功能。Keycloak 支持多种标准协议,包括 OpenID Connect 和 OAuth 2.0,这使得它能够与各种服务进行集成,以提供身份验证和授权功能。
部署Keycloak
由于生产环境是14.0版本,而keycloak官方已经不再支持该版本,所以我选择了bitnami封装的存档镜像用于部署测试环境,以下是docker-compose文件
version: '3.8'
services:
postgres:
restart: always
image: postgres:15-alpine
volumes:
- keycloak-data:/var/lib/postgresql/data:Z
environment:
- POSTGRES_USER=keycloak
- POSTGRES_PASSWORD=Admin@2023 # 数据库密码
- POSTGRES_DB=keycloak
- PGDATA=/var/lib/postgresql/data/pgdata
keycloak:
restart: always
image: bitnami/keycloak:14
depends_on:
- postgres
ports:
- "8088:8080"
- "9990:9990"
environment:
# 默认用户名为user
#- KEYCLOAK_ADMIN="admin"
- KEYCLOAK_ADMIN_PASSWORD="admin"
- KEYCLOAK_DATABASE_HOST=postgres
- KEYCLOAK_DATABASE_PORT=5432
- KEYCLOAK_DATABASE_NAME=keycloak
- KEYCLOAK_DATABASE_USER=keycloak
- KEYCLOAK_DATABASE_PASSWORD=Admin@2023 #数据库密码对齐
- quarkus.transaction-manager.enable-recovery=true
security_opt:
- seccomp:unconfined
volumes:
# 将主题目录映射到宿主机
- /var/keycloak/themes:/opt/bitnami/keycloak/themes/
# 定义持久化卷
volumes:
keycloak-data:
自定义主题
Keycloak部署启动后,此时为默认登录主题
进入主题目录,新建文件夹custom_login/login
,这里是我们自定义主题的工程目录,以下是目录文件结构
custom_login/
└── login
├── login.ftl # 登录窗体模板
├── messages
│ └── messages_en.properties # 自定义消息体
├── resources # 静态资源:css和logo图片
├── template.ftl # 主题layout
└── theme.properties # 主题属性
下面分别来看下各个文件内容
theme.properties
# 从keycloak继承主题
parent=keycloak
import=common/keycloak
styles=css/styles.css
meta=viewport==width=device-width,initial-scale=1
template.ftl
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false showAnotherWayIfPresent=true>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" class="${properties.kcHtmlClass!}">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="robots" content="noindex, nofollow">
<#if properties.meta?has_content>
<#list properties.meta?split(' ') as meta>
<meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/>
</#list>
</#if>
<title>${msg("loginTitle",(realm.displayName!''))}</title>
<link rel="icon" href="${url.resourcesPath}/img/favicon.ico" />
<#if properties.stylesCommon?has_content>
<#list properties.stylesCommon?split(' ') as style>
<link href="${url.resourcesCommonPath}/${style}" rel="stylesheet" />
</#list>
</#if>
<#if properties.styles?has_content>
<#list properties.styles?split(' ') as style>
<link href="${url.resourcesPath}/${style}" rel="stylesheet" />
</#list>
</#if>
<#if properties.scripts?has_content>
<#list properties.scripts?split(' ') as script>
<script src="${url.resourcesPath}/${script}" type="text/javascript"></script>
</#list>
</#if>
<#if scripts??>
<#list scripts as script>
<script src="${script}" type="text/javascript"></script>
</#list>
</#if>
</head>
<body class="${properties.kcBodyClass!}" style="display: flex;">
<div class="${properties.kcLoginClass!}">
<div id="kc-header" class="${properties.kcHeaderClass!}">
<div class="logo">
<img alt="Transwarp" src="${url.resourcesPath}/img/logo.png" width="250"/>
</div>
<div id="kc-header-wrapper"
class="${properties.kcHeaderWrapperClass!}">${kcSanitize(msg("loginTitleHtml",(realm.displayNameHtml!'')))?no_esc}</div>
</div>
<div class="${properties.kcFormCardClass!}">
<header class="${properties.kcFormHeaderClass!}">
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<div class="${properties.kcLocaleMainClass!}" id="kc-locale">
<div id="kc-locale-wrapper" class="${properties.kcLocaleWrapperClass!}">
<div id="kc-locale-dropdown" class="${properties.kcLocaleDropDownClass!}">
<a href="#" id="kc-current-locale-link">${locale.current}</a>
<ul class="${properties.kcLocaleListClass!}">
<#list locale.supported as l>
<li class="${properties.kcLocaleListItemClass!}">
<a class="${properties.kcLocaleItemClass!}" href="${l.url}">${l.label}</a>
</li>
</#list>
</ul>
</div>
</div>
</div>
</#if>
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#if displayRequiredFields>
<div class="${properties.kcContentWrapperClass!}">
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
</div>
<div class="col-md-10">
<h1 id="kc-page-title"><#nested "header"></h1>
</div>
</div>
<#else>
<h1 id="kc-page-title" style="margin: 10px 0 10px">统一身份单点登录系统</h1>
</#if>
<#else>
<#if displayRequiredFields>
<div class="${properties.kcContentWrapperClass!}">
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
</div>
<div class="col-md-10">
<#nested "show-username">
<div id="kc-username" class="${properties.kcFormGroupClass!}">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</div>
</div>
<#else>
<#nested "show-username">
<div id="kc-username" class="${properties.kcFormGroupClass!}">
<label id="kc-attempted-username">${auth.attemptedUsername}</label>
<a id="reset-login" href="${url.loginRestartFlowUrl}">
<div class="kc-login-tooltip">
<i class="${properties.kcResetFlowIcon!}"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</div>
</a>
</div>
</#if>
</#if>
</header>
<div id="kc-content">
<div id="kc-content-wrapper">
<#-- App-initiated actions should not see warning messages about the need to complete the action -->
<#-- during login. -->
<#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
<div class="alert-${message.type} ${properties.kcAlertClass!} pf-m-<#if message.type = 'error'>danger<#else>${message.type}</#if>">
<div class="pf-c-alert__icon">
<#if message.type = 'success'><span class="${properties.kcFeedbackSuccessIcon!}"></span></#if>
<#if message.type = 'warning'><span class="${properties.kcFeedbackWarningIcon!}"></span></#if>
<#if message.type = 'error'><span class="${properties.kcFeedbackErrorIcon!}"></span></#if>
<#if message.type = 'info'><span class="${properties.kcFeedbackInfoIcon!}"></span></#if>
</div>
<span class="${properties.kcAlertTitleClass!}">${kcSanitize(message.summary)?no_esc}</span>
</div>
</#if>
<#nested "form">
<#if auth?has_content && auth.showTryAnotherWayLink() && showAnotherWayIfPresent>
<form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<input type="hidden" name="tryAnotherWay" value="on"/>
<a href="#" id="try-another-way"
onclick="document.forms['kc-select-try-another-way-form'].submit();return false;">${msg("doTryAnotherWay")}</a>
</div>
</form>
</#if>
<#if displayInfo>
<div id="kc-info" class="${properties.kcSignUpClass!}">
<div id="kc-info-wrapper" class="${properties.kcInfoAreaWrapperClass!}">
<#nested "info">
</div>
</div>
</#if>
</div>
</div>
</div>
</div>
</body>
</html>
</#macro>
login.ftl
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section>
<#if section = "header">
${msg("loginAccountTitle")}
<#elseif section = "form">
<!-- 子标题 -->
<!--<div class="login-subtitle" data-qa-id="login-subtitle">${msg("signinIntro")}</div> -->
<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">
<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>
<#if usernameEditDisabled??>
<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" disabled />
<#else>
<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="off"
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>
</#if>
</div>
<div class="${properties.kcFormGroupClass!}">
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
<input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
</div>
<div class="${properties.kcFormGroupClass!} ${properties.kcFormSettingClass!}">
<div id="kc-form-options">
<#if realm.rememberMe && !usernameEditDisabled??>
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
<#else>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
</#if>
</label>
</div>
</#if>
</div>
<div class="${properties.kcFormOptionsWrapperClass!}">
<#if realm.resetPasswordAllowed>
<!-- 忘记密码链接 -->
<span><a tabindex="5" href="https://selfcare.transwarp.io/pwm/public/forgottenpassword">${msg("doForgotPassword")}</a></span>
</#if>
</div>
</div>
<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="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</form>
</#if>
</div>
<#if realm.password && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h4>${msg("identity-provider-login-label")}</h4>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>
<a id="social-${p.alias}" class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>"
type="button" href="${p.loginUrl}">
<#if p.iconClasses?has_content>
<i class="${properties.kcCommonLogoIdP!} ${p.iconClasses!}" aria-hidden="true"></i>
<span class="${properties.kcFormSocialAccountNameClass!} kc-social-icon-text">${p.displayName!}</span>
<#else>
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
</#if>
</a>
</#list>
</ul>
</div>
</#if>
</div>
<#elseif section = "info" >
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration-container">
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6"
href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</div>
</#if>
</#if>
</@layout.registrationLayout>
resources/css/styles.css
:root {
font-size: 14px;
--pf-global--BackgroundColor--light-100: #fff;
--pf-c-button--m-primary--BackgroundColor: #36383a;
--pf-global--primary-color--100: #36383a;
--pf-global--primary-color--200:#f2a92e
}
.login-pf {
background: none;
height: 100%;
width: 100%;
}
.login-pf body {
background: #e4e4e4;
font-family: Roboto;
height: 100%;
margin: 0;
}
#kc-header-wrapper {
display: none;
}
.login-pf-page {
height: 500px;
width: 1000px;
display: flex;
justify-content: center;
align-items: stretch;
margin: auto;
}
.login-pf-page-header {
height: 100%;
flex: 0 0 35em;
display: flex;
flex-direction: column;
justify-content: space-around;
border-top-left-radius: 50px;
border-bottom-left-radius: 50px;
background-color: #36383a;
}
.card-pf {
border-top: 0;
margin: 0;
flex: 0 0 35em;
display: flex;
flex-direction: column;
justify-content: center;
border-top-right-radius: 50px;
border-bottom-right-radius: 50px;
}
.login-pf-page .login-pf-header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 700;
line-height: 2rem;
text-align: left;
}
input[type="text"],
input[type="password"],
select {
height: 48px;
width: 100%;
padding: 12px 20px;
margin: 8px 0;
display: inline-block;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
input[type="submit"] {
width: 100%;
background-color: #4caf50;
color: white;
padding: 14px 20px;
margin: 8px 0;
border: none;
border-radius: 4px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #45a049;
}
#kc-form-options .checkbox {
padding: 0;
margin: 0;
}
.pf-c-form__label {
font-weight: bolder;
}
.pf-c-button,
.pf-c-button.pf-m-primary {
color: var(--pf-c-button--m-primary--Color);
background-color: var(--pf-c-button--m-primary--BackgroundColor);
}
.pf-m-primary:hover {
background-color: #f2a92e !important;
color: #36383a !important;
}
.login-subtitle {
font-size: 1rem;
line-height: 1.4rem;
padding: 16px 0 16px 0;
color: #3b97d5;
}
.pf-m-error {
color: #ff0000 !important;
}
@media (max-width: 750px) {
.login-pf-page-header,
.login-pf body {
background-color: #fff;
}
.login-pf-page {
flex-direction: column;
justify-content: center;
}
.login-pf-page-header {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
flex: 0 0 15em;
}
.card-pf {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
最后再上传我们的logo到resources/img/logo.png
应用主题
使用管理账户登录Keycloak后台,在域设置中选择login主题为custom_login,然后设置默认中文语言,应用主题后,退出登录即可查看效果