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,然后设置默认中文语言,应用主题后,退出登录即可查看效果