发布于 2026-01-06 6 阅读
0

如何创建 Keycloak 插件?DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

如何创建 Keycloak 插件

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

介绍

Keycloak是一款开源的身份和访问管理解决方案。对于需要实现自定义身份验证系统但自身能力不足以开发安全服务的小到大型团队来说,它非常实用。Keycloak 使用 Java 编写,并提供 SPI(服务提供商接口)。这意味着它可以通过插件轻松扩展,例如自定义实现现有类或添加新功能。

那么,如何创建插件呢?在本教程中,我将举例说明如何开发和测试 Keycloak 插件。

需要说明的是,虽然每个用户的需求各不相同,但你想要开发的插件很可能已经在用户thomasdarimont的这个优秀的代码库中存在了。务必去看看,至少可以从中获得一些灵感。

在本教程中,我们将开发一个插件,该插件会根据发送到用户邮箱的链接来验证用户身份。截至撰写本文时,上述代码库中还没有类似的示例。如果您想查看完整的项目,请访问我的 GitHub 仓库:https://github.com/yakovlev-alexey/keycloak-email-link-auth。提交历史大致与本教程一致。

目录

初始化项目

首先,我们来创建一个 Maven 项目。我个人更喜欢用 shell 命令来创建,但你也可以使用你喜欢的 IDE 来完成同样的操作。



mvn archetype:generate -DgroupId=dev.yakovlev_alexey -DartifactId=keycloak-email-link-auth-plugin -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeVersion=1.4 -DinteractiveMode=false


Enter fullscreen mode Exit fullscreen mode

请务必输入您自己的groupId信息artifactId

这将生成类似这样的结构



.
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── dev
    │           └── yakovlev_alexey
    │               └── App.java
    └── test
        └── java
            └── dev
                └── yakovlev_alexey
                    └── AppTest.java


Enter fullscreen mode Exit fullscreen mode

虽然完全可以创建单元测试,但目前我们暂不打算这样做。所以,让我们删除test文件夹和App.java文件。现在我们需要安装开发插件所需的依赖项。请对您的文件进行以下修改。pom.xml



    <properties>
        <!-- other properties -->
        <keycloak.version>19.0.3</keycloak.version>
    </properties>

    <dependencies>
        <!-- generated by maven - may be removed if you do not plan to unit test -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.11</version>
            <scope>test</scope>
        </dependency>

        <!-- HERE GO KEYCLAOK DEPENDENCIES -->
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi-private</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-services</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-parent</artifactId>
                <version>${keycloak.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>


Enter fullscreen mode Exit fullscreen mode

所有 Keycloak 依赖项均已指定provided。根据文档,这意味着 Maven 会期望运行时(JDK 或本例中的 Keycloak)提供这些依赖项。这正是我们所需要的。不过,我们仍然需要一种集中式的方法来管理 Keycloak 依赖项版本。为此,我们使用dependencyManagement属性。请务必运行mvn install以更新您的依赖项。

现在我们已经准备好开发我们的插件,不过最好先创建一个测试平台,这样我们就可以在本地快速测试我们的更改,而无需修改您实际的 Keycloak 实例,甚至无需部署它。

创建一个测试平台

让我们使用 Docker 和 docker-compose 创建一个测试平台。我建议从一开始就使用 docker-compose,因为你的插件很可能需要一些外部依赖项,例如 SMTP 服务器。使用 docker-compose 可以让你之后轻松添加其他服务,从而创建一个真正隔离的环境。我使用以下配置docker-compose.yaml



# ./docker/docker-compose.yaml
version: "3.2"

services:
    keycloak:
        build:
            context: ./
            dockerfile: Dockerfile
            args:
                - KEYCLOAK_IMAGE=${KEYCLOAK_IMAGE}
        environment:
            KEYCLOAK_ADMIN: admin
            KEYCLOAK_ADMIN_PASSWORD: admin
            DB_VENDOR: h2
        volumes:
            - ./h2:/opt/keycloak/data/h2
        ports:
            - "8024:8080"


Enter fullscreen mode Exit fullscreen mode

以及以下内容Dockerfile



# ./docker/Dockerfile
ARG KEYCLOAK_IMAGE

FROM $KEYCLOAK_IMAGE

USER root
COPY plugins/*.jar /opt/keycloak/providers/
USER 1000

ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start-dev"]


Enter fullscreen mode Exit fullscreen mode

为了使 docker-compose 正常工作,我们需要一些环境变量。请在.env文件中指定它们。



<!-- ./docker/.env -->
COMPOSE_PROJECT_NAME=keycloak
KEYCLOAK_IMAGE=quay.io/keycloak/keycloak:19.0.3


Enter fullscreen mode Exit fullscreen mode

plugins在目录内创建文件夹。编译好的插件文件将存放docker在这里。jar



docker
├── .env
├── Dockerfile
├── docker-compose.yaml
├── h2
└── plugins
    └── .gitkeep


Enter fullscreen mode Exit fullscreen mode

请确保忽略版本控制系统中的h2文件夹及其plugins内容。您的版本控制系统.gitignore可能如下所示:



# ./.gitignore
target
docker/h2

docker/plugins/*
!docker/plugins/.gitkeep


Enter fullscreen mode Exit fullscreen mode

最后,您可以docker-compose up --build keycloakdocker文件夹中运行命令来启动 Keycloak 的测试实例。

实现自定义身份验证器

现在回到我们的目标。我们希望根据发送到用户邮箱的链接来验证用户身份。为此,我们需要实现一个自定义身份验证器。身份验证器本质上是用户身份验证过程中的一个步骤。它可以是任何东西,从需要用户输入信息的表单到复杂的重定向。

为了了解如何创建身份验证器(或者任何其他需要利用 SPI 的类),我建议您查看托管在 GitHub 上的 Keycloak 源代码:https://github.com/keycloak/keycloak。正如您所料,Keycloak 的代码库非常庞大,因此使用 GitHub 搜索功能查找所需的类会更加便捷。在本例中,您可能会对以下authenticators目录感兴趣:https://github.com/keycloak/keycloak/tree/main/services/src/main/java/org/keycloak/authentication/authenticators。我不会详细介绍现有的 Keycloak 身份验证器及其实现,而是直接开始搭建我们自己的身份验证器。

我还建议遵循与 Keycloak 相同的文件夹结构。因此,我们的插件应该类似于这样:



src
└── main
    └── java
        └── dev
            └── yakovlev_alexey
                └── keycloak
                    └── authentication
                        └── authenticators
                            └── browser
                                └── EmailLinkAuthenticator.java


Enter fullscreen mode Exit fullscreen mode

创建类EmailLinkAuthenticator并实现Authenticator接口org.keycloak.authentication.Authenticator。在对所需方法进行存根后,您的代码可能如下所示:



package dev.yakovlev_alexey.keycloak.authentication.authenticators.browser;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

public class EmailLinkAuthenticator implements Authenticator {

    @Override
    public void close() {
        // TODO Auto-generated method stub
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // TODO Auto-generated method stub
    }

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // TODO Auto-generated method stub
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public boolean requiresUser() {
        // TODO Auto-generated method stub
        return false;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
        // TODO Auto-generated method stub
    }

}


Enter fullscreen mode Exit fullscreen mode

有很多方法,但我们只需要其中的一些。重要的方法是:authenticate当用户在身份验证流程中输入此身份验证器时调用的方法,以及action当用户提交表单时调用的方法。

首先,我们可以指定此身份验证器需要用户。这意味着,在用户访问此身份验证器时,我们应该已经知道用户想要以哪个身份进行身份验证。简单来说,在访问我们的身份验证器之前,用户应该输入他们的用户名。为了向 Keycloak 表明这一点,只需truerequiresUser方法返回即可。

configuredFor该方法允许我们告知 Keycloak 在特定上下文中是否能够验证用户身份。上下文指的是包含其设置的领域、Keycloak 会话以及已验证的用户。在本例中,我们只需要用户拥有电子邮件地址。因此,让我们user.getEmail() != null从……返回configuredFor

现在是时候实现这个身份验证器的实际逻辑了。我们需要它完成以下几项任务:

  1. 当身份验证器首次被调用时,发送一封包含链接的电子邮件,然后显示“电子邮件已发送”页面。
  2. 页面应该有一个按钮,用于重新发送电子邮件(以防发送失败)。
  3. 页面应该有一个按钮用于提交验证(例如,我已经在其他浏览器/设备上确认了验证,现在想在这个标签页继续)。

与我们最接近的身份验证器是VerifyEmailRequired Action。虽然它实际上并非身份验证器,但两者非常相似。查看其实现可知,为了发送电子邮件,它会创建一个令牌并将其编码到操作令牌 URL 中。



private Response sendVerifyEmail(KeycloakSession session, LoginFormsProvider forms, UserModel user, AuthenticationSessionModel authSession, EventBuilder event) throws UriBuilderException, IllegalArgumentException {
    RealmModel realm = session.getContext().getRealm();
    UriInfo uriInfo = session.getContext().getUri();

    int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(VerifyEmailActionToken.TOKEN_TYPE);
    int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;

    String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId();
    VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), absoluteExpirationInSecs, authSessionEncodedId, user.getEmail(), authSession.getClient().getClientId());
    UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo),
            authSession.getClient().getClientId(), authSession.getTabId());
    String link = builder.build(realm.getName()).toString();
    long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);

    try {
        session
            .getProvider(EmailTemplateProvider.class)
            .setAuthenticationSession(authSession)
            .setRealm(realm)
            .setUser(user)
            .sendVerifyEmail(link, expirationInMinutes);
        event.success();
    } catch (EmailException e) {
        logger.error("Failed to send verification email", e);
        event.error(Errors.EMAIL_SEND_FAILED);
    }

    return forms.createResponse(UserModel.RequiredAction.VERIFY_EMAIL);
}


Enter fullscreen mode Exit fullscreen mode

自定义操作令牌

我认为复制现有的实现是合理的,只是我们的参数会略有不同,以适应身份验证器和所需操作的不同上下文。但要正确使用此实现,我们需要自己的操作令牌。操作令牌是一个代表 JWT 的类。它继承自 `ActionToken` 类,DefaultActionToken并有一个对应的 ` ActionTokenHandlerActionToken` 类。令牌可以编码成一个 URL,操作令牌处理程序会响应该 URL。让我们通过复制一个现有的操作令牌来创建我们自己的操作令牌VerifyEmail

EmailLinkActionToken应该位于keycloak.authentication.actiontoken.emaillink软件包中。您的文件夹结构应如下所示:



src
└── main
    └── java
        └── dev
            └── yakovlev_alexey
                └── keycloak
                    └── authentication
                        ├── actiontoken
                        │   └── emaillink
                        │       ├── EmailLinkActionToken.java
                        │       └── EmailLinkActionTokenHandler.java
                        └── authenticators
                            └── browser
                                └── EmailLinkAuthenticator.java


Enter fullscreen mode Exit fullscreen mode

让我们EmailLinkActionToken通过复制VerifyEmailActionToken并替换名称并删除冒号来实现originalAuthenticationSessionId



package dev.yakovlev_alexey.keycloak.authentication.actiontoken.emaillink;

import org.keycloak.authentication.actiontoken.DefaultActionToken;

public class EmailLinkActionToken extends DefaultActionToken {
    public static final String TOKEN_TYPE = "email-link";

    public EmailLinkActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId,
            String email, String clientId) {
        super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
        this.issuedFor = clientId;

        setEmail(email);
    }

    private EmailLinkActionToken() {
    }
}


Enter fullscreen mode Exit fullscreen mode

您可能已经注意到我删除了它originalAuthenticationSessionId。它用于通过重新颁发令牌并使用该令牌的链接作为提交操作来创建新的身份验证会话,以在不同的浏览器中确认注册。

接下来,我们来实现它的处理程序。这次直接复制粘贴行不通:上下文差异很大。首先,我们确定一个实现方案,让用户点击链接后立即获得许可(不像之前VerifyEmail那样需要手动点击按钮)。点击链接后,会显示一个信息页面。为了实现这个功能,我们将大致遵循之前的步骤,VerifyEmailActionTokenHandler但我会尽量对重要的部分进行注释,因为原代码不太容易理解。



package dev.yakovlev_alexey.keycloak.authentication.actiontoken.emaillink;

import org.keycloak.authentication.actiontoken.AbstractActionTokenHandler;
import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.actiontoken.*;
import org.keycloak.events.*;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionCompoundId;
import org.keycloak.sessions.AuthenticationSessionModel;

import dev.yakovlev_alexey.keycloak.authentication.authenticators.browser.EmailLinkAuthenticator;

import java.util.Collections;

import javax.ws.rs.core.Response;

public class EmailLinkActionTokenHandler extends AbstractActionTokenHandler<EmailLinkActionToken> {

    public EmailLinkActionTokenHandler() {
        super(
                EmailLinkActionToken.TOKEN_TYPE,
                EmailLinkActionToken.class,
                Messages.STALE_VERIFY_EMAIL_LINK,
                EventType.VERIFY_EMAIL,
                Errors.INVALID_TOKEN);
    }

    @Override
    public Predicate<? super EmailLinkActionToken>[] getVerifiers(
            ActionTokenContext<EmailLinkActionToken> tokenContext) {
        // this is different to VerifyEmailActionTokenHandler implementation because
        // since its implementation a helper was added
        return TokenUtils.predicates(verifyEmail(tokenContext));
    }

    @Override
    public Response handleToken(EmailLinkActionToken token, ActionTokenContext<EmailLinkActionToken> tokenContext) {
        AuthenticationSessionModel authSession = tokenContext.getAuthenticationSession();
        UserModel user = authSession.getAuthenticatedUser();
        KeycloakSession session = tokenContext.getSession();
        EventBuilder event = tokenContext.getEvent();
        RealmModel realm = tokenContext.getRealm();

        event.event(EventType.VERIFY_EMAIL)
                .detail(Details.EMAIL, user.getEmail())
                .success();

        // verify user email as we know it is valid as this entry point would never have
        // gotten here
        user.setEmailVerified(true);

        // fresh auth session means that the link was open in a different browser window
        // or device
        if (!tokenContext.isAuthenticationSessionFresh()) {
            // link was opened in the same browser session (session is not fresh) - save the
            // user a click and continue authentication in the new (current) tab
            // previous tab will be thrown away
            String nextAction = AuthenticationManager.nextRequiredAction(tokenContext.getSession(), authSession,
                    tokenContext.getRequest(), tokenContext.getEvent());
            return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(),
                    authSession, tokenContext.getUriInfo(), nextAction);
        }

        AuthenticationSessionCompoundId compoundId = AuthenticationSessionCompoundId
                .encoded(token.getCompoundAuthenticationSessionId());

        AuthenticationSessionManager asm = new AuthenticationSessionManager(session);
        asm.removeAuthenticationSession(realm, authSession, true);

        ClientModel originalClient = realm.getClientById(compoundId.getClientUUID());
        // find the original authentication session
        // (where the tab is waiting to confirm)
        authSession = asm.getAuthenticationSessionByIdAndClient(realm, compoundId.getRootSessionId(),
                originalClient, compoundId.getTabId());

        if (authSession != null) {
            authSession.setAuthNote(EmailLinkAuthenticator.EMAIL_LINK_VERIFIED, user.getEmail());
        } else {
            // if no session was found in the same instance it might still be in the same
            // cluster if you have multiple replicas of Keycloak
            session.authenticationSessions().updateNonlocalSessionAuthNotes(
                    compoundId,
                    Collections.singletonMap(EmailLinkAuthenticator.EMAIL_LINK_VERIFIED,
                            token.getEmail()));
        }

        // show success page
        return session.getProvider(LoginFormsProvider.class)
                .setAuthenticationSession(authSession)
                .setSuccess(Messages.EMAIL_VERIFIED, token.getEmail())
                .createInfoPage();
    }

    // we do not really want users to authenticate using the same link multiple times
    @Override
    public boolean canUseTokenRepeatedly(EmailLinkActionToken token,
            ActionTokenContext<EmailLinkActionToken> tokenContext) {
        return false;
    }
}


Enter fullscreen mode Exit fullscreen mode

为了方便自己练习,请尝试重新实现此操作令牌和处理程序,以要求额外的确认(就像它与 一起工作一样VerifyEmailActionToken)。

身份验证器实现

操作令牌完成后,我们可以继续实施EmailLinkAuthenticator。我们将VerifyEmail.sendVerifyEmailEvent以此为参考,但由于上下文差异很大,我们将对很多内容进行更改。



    private static final Logger logger = Logger.getLogger(EmailLinkAuthenticator.class);

    protected void sendVerifyEmail(KeycloakSession session, AuthenticationFlowContext context, UserModel user)
            throws UriBuilderException, IllegalArgumentException {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        RealmModel realm = session.getContext().getRealm();

        // use the same lifespan as other tokens by getting from realm configuration
        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(EmailLinkActionToken.TOKEN_TYPE);
        long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);

        String link = buildEmailLink(session, context, user, validityInSecs);

        // event is used to achieve better observability over what happens in Keycloak
        EventBuilder event = getSendVerifyEmailEvent(context, user);

        Map<String, Object> attributes = getMessageAttributes(user, realm.getDisplayName(), link, expirationInMinutes);

        try {
            session.getProvider(EmailTemplateProvider.class)
                    .setRealm(realm)
                    .setUser(user)
                    .setAuthenticationSession(authSession)
                    // hard-code some of the variables - we will return here later
                    .send("emailLinkSubject", "email-link-email.ftl", attributes);

            event.success();
        } catch (EmailException e) {
            logger.error("Failed to send verification email", e);
            event.error(Errors.EMAIL_SEND_FAILED);
        }

        showEmailSentPage(context, user);
    }

    /**
     * Generates an action token link by encoding `EmailLinkActionToken` with user
     * and session data
     */
    protected String buildEmailLink(KeycloakSession session, AuthenticationFlowContext context, UserModel user,
            int validityInSecs) {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        RealmModel realm = session.getContext().getRealm();
        UriInfo uriInfo = session.getContext().getUri();

        int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;

        String authSessionEncodedId = AuthenticationSessionCompoundId.fromAuthSession(authSession).getEncodedId();
        EmailLinkActionToken token = new EmailLinkActionToken(user.getId(), absoluteExpirationInSecs,
                authSessionEncodedId, user.getEmail(), authSession.getClient().getClientId());
        UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo),
                authSession.getClient().getClientId(), authSession.getTabId());
        String link = builder.build(realm.getName()).toString();

        return link;
    }

    /**
     * Creates a Map with context required to render email message
     */
    protected Map<String, Object> getMessageAttributes(UserModel user, String realmName, String link,
            long expirationInMinutes) {
        Map<String, Object> attributes = new HashMap<>();

        attributes.put("user", user);
        attributes.put("realmName", realmName);
        attributes.put("link", link);
        attributes.put("expirationInMinutes", expirationInMinutes);

        return attributes;
    }

    /**
     * Creates a builder for `SEND_VERIFY_EMAIL` event
     */
    protected EventBuilder getSendVerifyEmailEvent(AuthenticationFlowContext context, UserModel user) {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();

        EventBuilder event = context.getEvent().clone().event(EventType.SEND_VERIFY_EMAIL)
                .user(user)
                .detail(Details.USERNAME, user.getUsername())
                .detail(Details.EMAIL, user.getEmail())
                .detail(Details.CODE_ID, authSession.getParentSession().getId())
                .removeDetail(Details.AUTH_METHOD)
                .removeDetail(Details.AUTH_TYPE);

        return event;
    }

    /**
     * Displays email link form
     */
    protected void showEmailSentPage(AuthenticationFlowContext context, UserModel user) {
        String accessCode = context.generateAccessCode();
        URI action = context.getActionUrl(accessCode);

        Response challenge = context.form()
                .setStatus(Response.Status.OK)
                .setActionUri(action)
                .setExecution(context.getExecution().getId())
                .createForm("email-link-form.ftl");

        context.forceChallenge(challenge);
    }


Enter fullscreen mode Exit fullscreen mode

此实现方式做了以下几件事:

  1. 创建一个事件,以便收集使用情况数据并在需要时进行调试。
  2. 生成指向操作令牌的链接
  3. 发送一封包含用户和链接等几个属性的电子邮件。
  4. 显示一个表单,允许您重新发送电子邮件或确认您已通过链接验证身份。

我暂时擅自硬编码了一些变量——之后我们会用实际的常量或配置参数替换它们。这些变量包括"emailLinkSubject"邮件主题、邮件和表单模板的消息 ID。"email-link-email.ftl"邮件"email-link-form.ftl"和模板存储在resourcesKeycloak 的某个目录中。我们稍后会将它们放在那里。

现在让我们实现最重要的方法:actionauthenticate



    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // the method gets called when first reaching this authenticator and after page
        // refreshes
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        KeycloakSession session = context.getSession();
        RealmModel realm = context.getRealm();
        UserModel user = context.getUser();

        // cant really do anything without smtp server
        if (realm.getSmtpConfig().isEmpty()) {
            ServicesLogger.LOGGER.smtpNotConfigured();
            context.attempted();
            return;
        }

        // if email was verified allow the user to continue
        if (Objects.equals(authSession.getAuthNote(EMAIL_LINK_VERIFIED), user.getEmail())) {
            context.success();
            return;
        }

        // do not allow resending e-mail by simple page refresh
        if (!Objects.equals(authSession.getAuthNote(Constants.VERIFY_EMAIL_KEY), user.getEmail())) {
            authSession.setAuthNote(Constants.VERIFY_EMAIL_KEY, user.getEmail());
            sendVerifyEmail(session, context, user);
        } else {
            showEmailSentPage(context, user);
        }
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // this method gets called when user submits the form
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        KeycloakSession session = context.getSession();
        UserModel user = context.getUser();

        // if link was already open continue authentication
        if (Objects.equals(authSession.getAuthNote(EMAIL_LINK_VERIFIED), user.getEmail())) {
            context.success();
            return;
        }

        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        String action = formData.getFirst("submitAction");

        // if the form was submitted with an action of `resend` resend the email
        // otherwise just show the same page
        if (action != null && action.equals("resend")) {
            sendVerifyEmail(session, context, user);
        } else {
            showEmailSentPage(context, user);
        }
    }


Enter fullscreen mode Exit fullscreen mode

我们最后要做的就是为身份验证器类创建一个工厂。Keycloak 使用工厂来实例化提供者。我们的操作令牌处理程序工厂是在基类中实现的。因此,完全可以在同一个类中同时实现提供者和工厂。但在这里,我们将把它放在一个不同的类中实现EmailLinkAuthenticatorFactory



package dev.yakovlev_alexey.keycloak.authentication.authenticators.browser;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;

public class EmailLinkAuthenticatorFactory implements AuthenticatorFactory {
    public static final EmailLinkAuthenticator SINGLETON = new EmailLinkAuthenticator();

    @Override
    public String getId() {
        return "email-link-authenticator";
    }

    @Override
    public String getDisplayType() {
        return "Email Link Authentication";
    }

    @Override
    public String getHelpText() {
        return "Authenticates the user with a link sent to their email";
    }

    @Override
    public String getReferenceCategory() {
        return null;
    }

    @Override
    public boolean isConfigurable() {
        return false;
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return new AuthenticationExecutionModel.Requirement[] {
                AuthenticationExecutionModel.Requirement.REQUIRED,
                AuthenticationExecutionModel.Requirement.DISABLED,
        };
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return null;
    }

    @Override
    public void init(Config.Scope config) {
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
    }

    @Override
    public void close() {
    }

    @Override
    public Authenticator create(KeycloakSession session) {
        // a common pattern in Keycloak codebase is to use singletons for factories
        return SINGLETON;
    }
}


Enter fullscreen mode Exit fullscreen mode

目前,我们的身份验证器不允许任何配置。此外,我们的身份验证器要么是必需的,要么是禁用的。这意味着无法通过不提供电子邮件地址来绕过此身份验证器。但是,如果您希望允许这种情况发生,您可以将此身份验证器设置为可选。您还需要调用context.attempted()身份authenticate验证器类中的一个方法。

允许没有邮箱的用户跳过此身份验证器的另一种方法是创建另一个条件身份验证器——但这超出了本教程的范围。

插件的自定义资源

现在我们已经有了实现所需功能的所有代码。但是,我们仍然缺少要显示的表单和消息。为了向插件添加资源,让我们创建一个resources目录src/main。在该目录中创建一个子目录theme-resources。Kecyloak 使用该子目录来导入模板和消息。

消息应存储在名为“Messages”的文件messages夹中.propertiesmessages_{language}.properties

模板也同样存储在templates文件夹中。电子邮件模板被分为多个htmltext文件夹。某些电子邮件客户端可能无法渲染 HTML,此时将使用纯文本版本。表单模板应存储在根目录中templates

其他资源也以类似的方式存储在 `<source>` css、 `<source>`js和` img<source>` 目录中。更多信息请参阅官方文档



src
└── main
    ├── java
    └── resources
        └── theme-resources
            ├── messages
            │   └── messages_en.properties
            └── templates
                ├── html
                │ └── email-link-email.ftl
                ├── text
                │ └── email-link-email.ftl
                └── email-link-form.ftl


Enter fullscreen mode Exit fullscreen mode

Keycloak 使用FreeMaker来存储和渲染模板。请阅读官方文档,了解更多关于 Keycloak 如何管理其主题的信息

为了在我们的插件中实现表单,email-link-form.ftl我们将使用以下模板:



<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
    <#if section = "header">
        ${msg("emailLinkTitle")}
    <#elseif section = "form">
        <form id="kc-register-form" action="${url.loginAction}" method="post">
            <div class="${properties.kcFormGroupClass!}">
                <button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="confirm" value="confirm">${msg("emailLinkCheck")}</button>
                <button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="submitAction" id="resend" value="resend">${msg("emailLinkResend")}</button>
            </div>
        </form>
    </#if>
</@layout.registrationLayout>


Enter fullscreen mode Exit fullscreen mode

msg该函数允许您插入本地化字符串resources/messages。渲染期间,Keycloak 会替换这些字符串。除此之外,语法与 FreeMaker 完全相同。

您可以看到我们的提交按钮有名称和值。当您通过按钮提交带有值的 HTML 表单时,该值会附加到发送到服务器的表单数据中。我们在action处理程序中正是利用这一点,确保只有在用户点击按钮请求重新发送电子邮件时,我们才会重新发送电子邮件。

至于电子邮件模板,看起来略有不同。HTML 版本email-link-email.ftl如下所示:



<html>
<body>
${kcSanitize(msg("emailLinkEmailBodyHtml",link, expirationInMinutes, realmName))?no_esc}
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

这里我们只使用一条消息来存储邮件的所有内容。我们还会传递一些变量,这些变量会被替换到消息中,稍后会详细介绍。no_esc这允许我们将 HTML 渲染为 HTML 而不是文本。并且,kcSanitize这是为了确保最终文档中不会出现任何危险的标记。

同一封邮件的文本版本如下所示:



<#ftl output_format="plainText">
${msg("emailLinkEmailBody",link, expirationInMinutes, realmName)}


Enter fullscreen mode Exit fullscreen mode

这里我们明确指出我们需要的是纯文本文件而不是 HTML 文件。除此之外,其他部分基本相同,只是我们这里不需要 HTML,因此既不调用 `next` 也不调用 ` kcSanitizenext` no_esc

目前我们已经有了所有模板,但缺少模板(以及提供商)使用的任何消息。我们的messages_en.properties文件应该类似于这样:



emailLinkSubject=Your authentication link

emailLinkResend=Resend email
emailLinkCheck=I followed the link

emailLinkEmailBody=Your authentication link is {0}.\nIt will be valid for {1} minutes. Follow it to authenticate in {2} and then return to the original browser tab.
emailLinkEmailBodyHtml=<a href="{0}">Click here to authenticate</a>. The link will be valid for {1} minutes. Follow it to authenticate in {2} and then return to the original browser tab.


Enter fullscreen mode Exit fullscreen mode

您可以看到,这只是一个包含所有必要消息的文件。如果您指定了 Keycloak 中已存在的消息,它们不会被替换。要替换现有消息,请使用主题。某些消息可能包含 HTML 标记。但正如我之前所说,它们需要经过适当的清理和转义才能使用。

通知 Keycloak 新的提供商

至此,我们已经有了插件的代码和资源。但是,如果我们构建它,将其放入 Keycloak 插件文件夹并运行 Keycloak,却不会有任何反应。Keycloak 不知道如何处理我们的代码。为了告诉 Keycloak 我们需要注入某些类,我们必须在构建的jar文件中添加一些元信息。为此,请使用resources/META-INF目录。



src
└── main
    ├── java
    └── resources
        ├── theme-resources
        └── META-INF
            └── services
                ├── org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory
                └── org.keycloak.authentication.AuthenticatorFactory


Enter fullscreen mode Exit fullscreen mode

这里我们在services子目录中创建了两个文件。该目录中的每个文件都应该以我们自己的类所实现或继承的基本接口或类命名。

在每个文件中,我们需要在单独的行中指定所有继承自文件名类的自定义类。



# org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory
dev.yakovlev_alexey.keycloak.authentication.actiontoken.emaillink.EmailLinkActionTokenHandler

# org.keycloak.authentication.AuthenticatorFactory
dev.yakovlev_alexey.keycloak.authentication.authenticators.browser.EmailLinkAuthenticatorFactory


Enter fullscreen mode Exit fullscreen mode

最后,我们可以运行命令来构建项目。应该会在文件夹中创建mvn clean package一个名为的文件。将此文件复制到目录中。keycloak-email-link-auth-plugin-1.0-SNAPSHOT.jartargetdocker/plugins

配置测试平台

我们的插件测试工作已接近尾声。但是,由于我们的插件会发送电子邮件,因此我们需要一个 SMTP 服务器才能进行测试。您可以使用真正的 SMTP 服务器,但这可能需要付费,而且您还需要使用一个真实的邮箱地址。使用像MailHog这样的邮件陷阱服务要简单得多。

将其添加为服务docker-compose.yaml



version: "3.2"

services:
    keycloak:
        build:
            context: ./
            dockerfile: Dockerfile
            args:
                - KEYCLOAK_IMAGE=${KEYCLOAK_IMAGE}
        environment:
            KEYCLOAK_ADMIN: admin
            KEYCLOAK_ADMIN_PASSWORD: admin
            DB_VENDOR: h2
        volumes:
            - ./h2:/opt/keycloak/data/h2
        ports:
            - "8024:8080"

    mailhog:
        image: mailhog/mailhog
        ports:
            - 1025:1025
            - 8025:8025


Enter fullscreen mode Exit fullscreen mode

端口 1025 用于 SMTP,端口 8025 用于 Web UI/API。

现在您可以使用 Docker 运行 Docker docker-compose up --build keycloak。访问http://localhost:8024并进入管理控制台。用户名和密码应admin与 YAML 文件中指定的一致。

要使用我们的身份验证器,您需要配置一个使用它的身份验证流程。流程是一系列身份验证器的序列。默认情况下browser使用身份验证流程。您可以将其复制到标签页browser-emailAuthentication

替换Username Password FormUsername Form。由于我们希望基于发送到电子邮件的链接进行身份验证,因此我们实际上不需要用户的密码。当然,也可以同时保留密码和电子邮件地址,以实现某种双因素身份验证。

Username Form加上我们的配料Email Link Authentication,就完成了Required

流程截图

Realm Settings接下来,在->中配置 SMTP 服务器Email。可以指定任意From值。连接主机应为mailhog,端口应为1025。不使用 SSL 或身份验证。

SMTP 配置

保存电子邮件配置后运行。电子邮件应该会成功发送,您应该会在 MailHog Web UI( http://localhost:8025/Test Connection中看到一条测试消息。

MailHog 用户界面

请确保管理员用户在“电子邮件地址”选项卡中填写了有效的电子邮件地址(不一定是您有权访问的电子邮件地址)Users

最后,进入配置选项卡Clients,找到account-console并配置浏览器流程。这样,您就可以通过输入http://localhost:8024/realms/master/account/轻松测试插件,即使更改无效也不会导致管理面板崩溃。AdvancedAuthentication flow overridesbrowser-email

客户端中的身份验证流程覆盖

一切就绪,现在您可以访问http://localhost:8024/realms/master/account/并输入您的用户名。您应该会看到我们之前实现的表单,并且邮件应该会在http://localhost:8025/ 的adminMailHog UI 中显示

电子邮件验证表单

请点击邮件中的链接。

MailHog UI 中的消息

您应该看到链接已验证。

已验证链接

返回原标签页,瞧!您现在应该已经通过身份验证了。

已在账户控制台中验证身份

身份验证器配置

您可能还记得,我们把一些应该可配置或者至少单独存储的变量硬编码到了代码里。我们回头来修正一下。

创建一个文件夹来存储我们身份验证器的所有实用程序类。



src
└── main
    └── java
        └── dev
            └── yakovlev_alexey
                └── keycloak
                    └── authentication
                    │ ├── actiontoken
                    │ └── authenticators
                    └── emaillink
                        ├── ConfigurationProperties.java
                        ├── Constants.java
                        └── Messages.java


Enter fullscreen mode Exit fullscreen mode

ConfigurationProperties.java这是一个包含常量以支持身份验证器配置的类。Constants.java它存储一些无需配置的通用变量,并Messages.java包含插件所需的所有消息字符串键。有些开发者只将 Java 代码需要的键放在这里,但我更倾向于使用Messages包含所有字符串(即使它们只在模板中使用)的列表来存储这些字符串,这样可以方便地添加本地化版本(例如,在配置messages_de.properties文件中)。

让我们来实现ConfigurationProperties这个类:



package dev.yakovlev_alexey.keycloak.emaillink;

import org.keycloak.provider.ProviderConfigProperty;

import java.util.Arrays;
import java.util.List;

import static org.keycloak.provider.ProviderConfigProperty.*;

public final class ConfigurationProperties {
    public static final String EMAIL_TEMPLATE = "EMAIL_TEMPLATE";
    public static final String PAGE_TEMPLATE = "PAGE_TEMPLATE";

    public static final String RESEND_ACTION = "RESEND_ACTION";

    public static final List<ProviderConfigProperty> PROPERTIES = Arrays.asList(
            new ProviderConfigProperty(EMAIL_TEMPLATE,
                    "FTL email template name",
                    "Will be used as the template for emails with the link",
                    STRING_TYPE, "email-link-email.ftl"),
            new ProviderConfigProperty(PAGE_TEMPLATE,
                    "FTL page template name",
                    "Will be used as the template for email link page",
                    STRING_TYPE, "email-link-form.ftl"),
            new ProviderConfigProperty(RESEND_ACTION,
                    "Resend Email Link action",
                    "Action which corresponds to user manually asking to resend email with link",
                    STRING_TYPE, "resend"));

    private ConfigurationProperties() {
    }
}


Enter fullscreen mode Exit fullscreen mode

还有Constants班级:



package dev.yakovlev_alexey.keycloak.emaillink;

public final class Constants {
    public static final String EMAIL_LINK_SUBMIT_ACTION_KEY = "submitAction";

    private Constants() {
    }
}


Enter fullscreen mode Exit fullscreen mode

最后是Messages班级:



package dev.yakovlev_alexey.keycloak.emaillink;

public final class Messages {
    public static final String EMAIL_LINK_SUBJECT = "emailLinkSubject";
    public static final String EMAIL_LINK_STALE = "emailLinkStale";

    public static final String EMAIL_LINK_SUCCESS = "emailLinkSuccess";

    public static final String EMAIL_LINK_TITLE = "emailLinkTitle";
    public static final String EMAIL_LINK_RESEND = "emailLinkResend";
    public static final String EMAIL_LINK_CHECK = "emailLinkCheck";

    public static final String EMAIL_LINK_EMAIL_BODY = "emailLinkEmailBody";
    public static final String EMAIL_LINK_EMAIL_BODY_HTML = "emailLinkEmailBodyHtml";

    private Messages() {
    }
}


Enter fullscreen mode Exit fullscreen mode

需要做一些修改EmailLinkAuthenticatorFactory才能使其可配置:



    @Override
    public boolean isConfigurable() {
        return true;
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return ConfigurationProperties.PROPERTIES;
    }


Enter fullscreen mode Exit fullscreen mode

现在更新EmailLinkAuthenticator类以利用新的常量:



    // ***

    @Override
    public void action(AuthenticationFlowContext context) {
        // this method gets called when user submits the form
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        KeycloakSession session = context.getSession();
        UserModel user = context.getUser();

        // if link was already open continue authentication
        if (Objects.equals(authSession.getAuthNote(EMAIL_LINK_VERIFIED), user.getEmail())) {
            context.success();
            return;
        }

        MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
        String action = formData
                .getFirst(dev.yakovlev_alexey.keycloak.emaillink.Constants.EMAIL_LINK_SUBMIT_ACTION_KEY);

        // if the form was submitted with an action of `resend` resend the email
        // otherwise just show the same page
        if (action != null && action.equals(config.getConfig().get(ConfigurationProperties.RESEND_ACTION))) {
            sendVerifyEmail(session, context, user);
        } else {
            showEmailSentPage(context, user);
        }
    }

    // ***

    private void sendVerifyEmail(KeycloakSession session, AuthenticationFlowContext context, UserModel user)
            throws UriBuilderException, IllegalArgumentException {
        AuthenticationSessionModel authSession = context.getAuthenticationSession();
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        RealmModel realm = session.getContext().getRealm();

        // use the same lifespan as other tokens by getting from realm configuration
        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(EmailLinkActionToken.TOKEN_TYPE);
        long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);

        String link = buildEmailLink(session, context, user, validityInSecs);

        // event is used to achieve better observability over what happens in Keycloak
        EventBuilder event = getSendVerifyEmailEvent(context, user);

        Map<String, Object> attributes = getMessageAttributes(user, realm.getDisplayName(), link, expirationInMinutes);

        try {
            session.getProvider(EmailTemplateProvider.class)
                    .setRealm(realm)
                    .setUser(user)
                    .setAuthenticationSession(authSession)
                    // hard-code some of the variables - we will return here later
                    .send(config.getConfig().get(Messages.EMAIL_LINK_SUBJECT),
                            config.getConfig().get((ConfigurationProperties.EMAIL_TEMPLATE)), attributes);

            event.success();
        } catch (EmailException e) {
            logger.error("Failed to send verification email", e);
            event.error(Errors.EMAIL_SEND_FAILED);
        }

        showEmailSentPage(context, user);
    }


    // ***

    /**
     * Displays email link form
     */
    protected void showEmailSentPage(AuthenticationFlowContext context, UserModel user) {
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        String accessCode = context.generateAccessCode();
        URI action = context.getActionUrl(accessCode);

        Response challenge = context.form()
                .setStatus(Response.Status.OK)
                .setActionUri(action)
                .setExecution(context.getExecution().getId())
                .createForm(config.getConfig().get(ConfigurationProperties.PAGE_TEMPLATE));

        context.forceChallenge(challenge);
    }


Enter fullscreen mode Exit fullscreen mode

您可以通过以下方式访问身份验证器配置context.getAuthenticatorConfig()。由于与同一文件中使用的ConstantsKeycloak 类重叠,因此为避免名称冲突,此处指定了完整的包名。Constants

现在,当您进入Authentication标签页并编辑browser-email流程时,您应该会在旁边看到一个齿轮按钮,Email Likn Authentication点击即可打开此身份验证器的设置。

Keycloak 19 的界面存在一个错误,导致无法打开身份验证器的设置。请参阅此问题

下一步

为了确保你真正掌握如何开发 Keycloak 插件,我建议你先自己做一些“功课”。关于这个插件,我之前已经提到过一些可以改进的地方。

  1. 实现一个配置选项,允许用户在打开链接时显示确认页面。该页面应包含一个“确认”按钮。只有点击此按钮后,用户才能继续在原标签页中操作。如果链接在同一浏览器窗口中打开,则用户应立即通过身份验证,就像现在这样。

  2. 创建一个独立的插件来实现条件验证器HasEmailCondition。您可以ConditionalUserAttributeValue参考现有插件。在测试环境中使用这两个插件配置一个流程:已设置密码的用户通过密码进行身份验证,而其他用户则需要点击邮件中的链接进行验证。

结论

希望这篇教程能让你了解如何创建 Keycloak 插件。Keycloak 插件的功能远不止这些,你可以实现许多内部 SPI 来应对不同的场景。我的目标是专注于基础知识:插件组成、资源导入以及如何从 Keycloak 源代码中汲取灵感。如果你有任何问题、想法或建议,请在GitHub issues中留言。

在我的 GitHub 上可以找到完整的代码 - https://github.com/yakovlev-alexey/keycloak-email-link-auth

文章来源:https://dev.to/yakovlev_alexey/how-to-create-a-keycloak-plugin-3acj