如何创建KeyCloak插件
#网络开发人员 #教程 #java #keycloak

介绍

Keycloak是一种开源身份和访问管理解决方案。我发现这对于需要实施自己的身份验证系统但没有能力自己开发安全服务的小团队非常有用。它用Java编写,并提供SPI(Service Provider Interface)。这意味着通过插件的现有类和新添加的自定义实现很容易扩展。

那么如何创建插件?在本教程中,我想举一个有关如何开发和测试KeyCloak插件的示例。

我应该注意,尽管每个用户的需求都不同,但用户thomasdarimontthis brilliant repo中已经存在您要开发的插件。确保看一看并至少获得灵感。

在本教程中,我们将开发一个插件,该插件将根据发送到电子邮件的链接来验证用户。在撰写本文时,没有类似的示例中的前库存储库中存在这样的示例。如果您正在寻找完整的项目,请访问我的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

确保输入自己的groupIdartifactId

这将产生像这样的结构

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

目前,尽管这是完全可能的,但我们不会创建单元测试。因此,让我们删除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>

所有keyCloak依赖项均指定为provided。根据documentation的说法,这意味着Maven将期望运行时(JDK或我们的情况下的KeyCloak)提供这些依赖性。这正是我们需要的。我们仍然需要一种集中的方式来管理KeyCloak依赖性版本。为此,我们使用koude6 property。确保运行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"

和以下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"]

为了使Docker-Composes起作用,我们需要一些环境变量。在.env文件中指定它们。

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

docker目录中创建plugins文件夹。这是带有编译插件的jar文件。

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

确保忽略VC中的h2文件夹和plugins内容。您的.gitignore看起来像这样:

# ./.gitignore
target
docker/h2

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

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

实施自定义身份验证者

现在回到我们的目标。我们希望根据发送给他们的电子邮件的链接来验证用户。为此,我们实施了自定义Authenticator。身份验证器基本上是对用户进行身份验证的过程的一步。它可能是需要从用户到复杂重定向的输入的表单。

为了了解如何创建身份验证器(或您可能需要的其他类别的spi spi),我建议您查看在Github -https://github.com/keycloak/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

创建EmailLinkAuthenticator类并实现了org.keycloak.authentication.AuthenticatorAuthenticator接口。经过固执之后,您的代码可能看起来像这样:

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
    }

}

有很多方法,但我们只需要其中一些。重要的是authenticate,当用户输入身份验证流和action中时,它被称为,当用户提交表单时称为。

首先,我们可以指定该身份验证者需要用户。这基本上意味着,在到达此身份​​验证器时,我们应该已经知道用户可能想要对谁进行身份验证。用更简单的术语在达到我们的身份验证器之前,用户应输入其用户名。为了向KeyCloak展示,只需从requiresUser方法返回true

configuredFor方法允许我们告诉KeyCloak我们是否能够在某个上下文中对用户进行身份验证。上下文是其设置,KeyCloak会话和用户正在验证的领域。就我们而言,我们只需要用户发送电子邮件。因此,让我们从configuredFor返回user.getEmail() != null

现在是时候为此身份验证器实现实际逻辑了。我们需要它做一些事情:

  1. 当验证者第一次被调用时,请发送带有链接的电子邮件,然后显示“电子邮件发送”页面
  2. 页面应该有一个按钮来重新发送电子邮件(以防交货失败)
  3. 页面应该有一个按钮来提交验证(例如,我在其他浏览器/设备中确认了验证,并希望在此选项卡中继续)

最接近我们的身份验证者是VerifyEmail所需的操作。虽然它并不是真正的身份验证器,但非常相似。 Looking at the implementation我们看到,为了发送电子邮件,它会创建一个令牌并在动作令牌中编码它。

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);
}

自定义动作令牌

我认为复制实现是有意义的,除非我们的参数略有不同,以说明身份验证者的不同上下文和重新措施的操作。但是要正确使用此实施,我们需要自己的操作令牌。动作令牌是代表JWT的类。它扩展了DefaultActionToken类,并具有相应的ActionTokenHandler类。代币可以编码为ACTION代币处理程序会响应的URL。让我们通过复制VerifyEmail One来创建自己的动作令牌。

EmailLinkActionToken应位于keycloak.authentication.actiontoken.emaillink包中。您的文件夹结构应该看起来像以下一个:

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

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

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() {
    }
}

您可能会注意到我删除了originalAuthenticationSessionId。它用于通过重新发行令牌并将其链接作为提交操作来创建新的Authenticaton会话,以确认在其他浏览器中注册。

接下来,让我们为其实现处理程序。这次仅复制将无效:上下文是非常不同的。首先,让我们解决一个实现,访问链接立即给予用户同意(与需要手动单击按钮的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;
    }
}

练习一点尝试重新进化此操作令牌和处理程序以需要其他确认(例如,与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);
    }

此实现有几件事:

  1. 创建一个事件,该事件将允许您在需要时收集有关使用情况的数据
  2. 生成一个动作令牌的链接
  3. 发送带有一些属性的电子邮件,包括用户和链接
  4. 显示了一种表格,该表格使您可以重新发送电子邮件或确认您通过链接验证了身份验证

我将自由暂时暂时进行了数个变量 - 我们将返回以实际常数或饱受的侵犯参数替换它们。这包括"emailLinkSubject"是电子邮件主题和电子邮件和表单模板的消息ID:"email-link-email.ftl""email-link-form.ftl"。消息和模板存储在KeyCloak中的resources目录中。我们稍后将它们放在那里。

现在让我们实现最重要的方法: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);
        }
    }

我们要使用Authenticator类要做的最后一件事是为其创建工厂。 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;
    }
}

目前,我们不允许对我们的身份验证器进行任何配置。另外,我们的身份验证者是必需的或禁用的。这意味着不可能通过没有电子邮件绕过此身份验证者。但是,如果这是您想要的,则可以使此身份验证器可选。您还需要在Authenticator类中的authenticate方法中调用context.attempted()

允许Emailless用户跳过此身份验证器的另一个选项是创建另一个条件身份验证器 - 但是,这是本教程的范围。

插件的自定义资源

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

邮件应存储在messages文件夹中,以.properties的名称为messages_{language}.properties

模板类似地存储在templates文件夹中。电子邮件模板分为htmltext子文件夹。一些电子邮件客户端可能不会渲染HTML-然后将使用文本版本。形式模板应存储在templates的根部。

其他资源也同样存储在cssjsimg目录中。在official docs中阅读更多有关的信息。

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

KeyCloak使用FreeMaker存储和渲染模板。阅读有关KeyCloak如何在official documentation中管理其主题的更多信息。

要在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>

msg功能允许您从resources/messages放置局部字符串。在渲染过程中,它们将由KeyCloak代替。否则语法是纯正的。

您可以看到我们的提交按钮具有名称和值。当您通过具有值的按钮提交HTML表单时,该值将附加以形式发送到服务器。这是我们在action处理程序中使用的内容,以确保我们仅在用户通过单击按钮来要求它时重新发送电子邮件。

至于电子邮件模板看起来有些不同。 email-link-email.ftl中的html版本看起来像:

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

在这里,我们只对电子邮件的所有内容都使用一条消息。我们还传递了可以替换为消息的变量,稍后会详细介绍。 no_esc允许我们将HTML呈现为HTML,而不是文本。并且需要kcSanitize以确保最终不会在生成的文档中出现危险的加价。

同一电子邮件的文本版本如下:

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

在这里,我们明确地说我们需要一个纯文本文档,而不是HTML文件。否则基本上是相同的,除非我们不想要html在这里

在这一点上,我们拥有所有模板,除非我们没有模板(和提供商)使用的任何消息。我们的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.

您只能看到它的文件,其中包含我们需要的所有消息。如果您指定了KeyCloak中已经存在的任何消息,则不会更换它们。要替换现有消息,请使用themes。一些消息可能包含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

在这里,我们在services子目录中创建了2个文件。该目录中的每个文件应以我们自己的类实现或扩展的基本接口或类命名。

在每个文件中,我们需要指定自己的所有类,这些类别从单独的行上的文件名类派生。

# 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

最后,我们可以通过运行mvn clean package来构建我们的项目。应在target文件夹中创建带有keycloak-email-link-auth-plugin-1.0-SNAPSHOT.jar名称的文件。将此文件复制到docker/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

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

现在您可以使用docker-compose up --build keycloak运行Docker。访问http://localhost:8024并进入管理控制台。用户名和密码都应如Yaml中所述。

中所述。

要使用我们的身份验证器,您需要配置使用它的身份验证器流。流是身份验证者的序列。默认情况下,使用browser身份验证流。继续并将其复制为browser-emailAuthentication选项卡中。

Username Form替换Username Password Form。由于我们要根据发送到电子邮件的链接进行身份验证,因此我们并不真正想要用户的密码。尽管也可以保留密码和电子邮件以实现2个因子身份验证。

Username Form添加我们的Email Link Authentication并将其制成Required

Flows screenshot

下一个在Realm Settings-> Email中配置SMTP服务器。指定任何From值。连接的主机应为mailhog和port 1025。不使用SSL或身份验证。

SMTP configuration

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

MailHog UI

确保您的管理员用户在Users选项卡中具有有效的电子邮件(不一定是您可以访问的电子邮件)。

最终输入Clients,查找account-console和在Advanced Tab配置Authentication flow overrides中。浏览器流量应使用browser-email超过轨道。这将使您可以通过输入http://localhost:8024/realms/master/account/轻松测试插件,并且如果您的更改不起作用,则不要打破管理面板。

Authentication flow overrides in client

一切都设置了,您现在可以访问http://localhost:8024/realms/master/account/并输入admin作为您的用户名。您应该看到我们之前实施的表格,并在http://localhost:8025/的MailHog UI中可见一封电子邮件。

Email authentication form

访问消息中的链接。

Message in MailHog UI

您应该看到链接已被验证。

Verified link

返回原始标签,瞧!您现在应该进行身份验证。

Authenticated in account console

身份验证器配置

您可能还记得我们硬编码了一些应该是可配置或至少单独存储的变量。让我们回去解决这个问题。

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

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

ConfigurationProperties.java是一个具有常数的类,可为我们的身份验证器启用配置。 Constants.java存储不需要配置的通用变量。 Messages.java包含我们插件需求的所有消息字符串键。一些开发人员只将Java代码需要的键放在那里,但我更喜欢使用Messages类作为所有字符串的列表(即使它们仅在模板中使用),以便易于添加本地化版本(例如messages_de.properties文件)。 /p>

让我们实现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() {
    }
}

Constants类:

package dev.yakovlev_alexey.keycloak.emaillink;

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

    private Constants() {
    }
}

最后是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() {
    }
}

需要进行一些更改,以使其可配置:

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

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

现在更新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);
    }

您可以通过context.getAuthenticatorConfig()访问身份验证器配置。 Constants与keycloak Constants类重叠,因此在此指定的名称完整包名称。

>

现在,当您输入Authentication选项卡并编辑browser-email流时,您应该在Email Likn Authentication旁边看到一个齿轮按钮,该按钮将为此身份验证器打开设置。

在KeyCloak 19中,接口中有一个错误,不允许为身份验证器打开设置。请参阅this issue

下一步

要确保您对如何开发KeyCloak插件有很好的了解,我建议您自己执行一些“作业”任务。我已经提到的有关此插件可以改进一些事情。

  1. 实现一个配置选项,该选项将在打开链接时启用确认屏幕。此屏幕应具有“确认”按钮。只有单击此按钮后,您才能在原始标签中继续。如果在同一浏览器窗口中打开链接,则应该对其进行无效的身份验证。

  2. 创建一个单独的插件,该插件实现条件身份验证器HasEmailCondition。您可以使用koude125作为参考。在测试工作台中使用两个插件来配置使用密码设置的用户通过密码进行身份验证的流程,而其他插件则需要遵循电子邮件链接。

结论

我希望本教程为您提供了一个想法,如何创建KeyCloak插件。还有很多事情,可以实现许多内部SPI来实现不同的情况。我的目标是专注于基础知识:插件组成,资源导入和从KeyCloak源代码获得灵感。如果您有任何疑问,想法或建议,请确保将它们留在GitHub issues中。

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