Как добавить кастомный аутентификатор в KeyCloak и подружить его со сторонней системой

Всем привет.

Сегодня мы покажем вам простой пример, как в Keycloak можно добавить кастомный аутентификатор.

Как вы все знаете, Keycloak – это система адаптивной аутентификации, позволяющая реализовать фактические любой процесс аутентификации (ограниченный только навыками разработки на Java) и выступать в качестве Identity Provider для клиентов по протоколам OIDC и SAML.

В стандартном наборе представлено много типовых аутентификаторов. Но что делать, когда стандартных аутентификаторов недостаточно и необходимо реализовать свою логику? Официальная документация дает ответ: разработать аутентификатор самому: https://www.keycloak.org/docs/latest/server_development/#_auth_spi 

Что мы в итоге и сделали.

Пример аутентификаторов:

Аутентификатор представляет собой jar-файл, разработанный определенным способом (описанным в документации). Его нужно добавить в определенную папку, в моем случае:…/keycloak-21.0.2/providers

После чего необходимо выполнить bin/kc.sh build

И аутентификатор появится в общем перечне. Если все пройдет нормально.

Структура стенда

Итак, представим, что нам нужно выполнить запрос по REST API к сторонней системе и получить от нее разрешение на вход пользователя.

Собираем вот такой стенд:

Клиент

В качестве целевого веб-сервера используем apache2 с установленным модулем mod_auth_openidc, позволяющим ему выступать в качестве OIDC-клиента: https://github.com/OpenIDC/mod_auth_openidc 

Конфигурация Apache2 будет выглядеть так:

<VirtualHost *:80>

        # The ServerName directive sets the request scheme, hostname and port that

        # the server uses to identify itself. This is used when creating

        # redirection URLs. In the context of virtual hosts, the ServerName

        # specifies what hostname must appear in the request's Host: header to

        # match this virtual host. For the default virtual host (this file) this

        # value is not decisive as it is used as a last resort host regardless.

        # However, you must set it for any further virtual host explicitly.

        #ServerName www.example.com

        ServerAdmin webmaster2@localhost

        DocumentRoot /var/www/askar_test

        ServerName askar.test.local

        #this is required by mod_auth_openidc

        OIDCSSLValidateServer Off 

        OIDCProviderMetadataURL https://mykeycloak:8443/realms/master/.well-known/openid-configuration

        OIDCClientID apache2

        OIDCClientSecret 7JNzjSh060t7ddBKLhlZ4oMp2jltDZae

        OIDCRedirectURI http://askar.test.local/index.html

        # maps the preferred_username claim to the REMOTE_USER environment variable

        OIDCRemoteUserClaim preferred_username

        <Location />

                AuthType openid-connect

                Require valid-user

        </Location>

        ErrorLog ${APACHE_LOG_DIR}/error.log

        CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

Тут важно отметить, что мы в целях теста отключили проверку подлинности по SSL с помощью опции OIDCSSLValidateServer Off. В проде, конечно же, делать этого нельзя.

В качестве тестового сайта создаем php-файлик:

<!DOCTYPE html>

<html lang="en">

<head>

   <meta charset="utf-8">

   <meta http-equiv="X-UA-Compatible" content="IE=edge">

   <meta name="viewport" content="width=device-width, initial-scale=1">

   <meta name="description" content="">

   <meta name="author" content="">

   <title>OpenID Connect: Received Claims</title>

</head>

<body>

         <h3>

            Claims sent back from OpenID Connect via the Apache module

         </h3>

         <br/>

   <!-- OpenAthens attribtues -->

      <?php session_start(); ?>

         <h2>Claims</h2>

         <br/>

         <div class="row">

               <table class="table" style="width:80%;" border="1">

                 <?php foreach ($_SERVER as $key=>$value): ?>

                    <?php if ( preg_match("/OIDC_/i", $key) ): ?>

                       <tr>

                          <td data-toggle="tooltip" title=<?php echo $key; ?>><?php echo $key; ?></td>

                          <td data-toggle="tooltip" title=<?php echo $value; ?>><?php echo $value; ?></td>

                       </tr>

                    <?php endif; ?>

                 <?php endforeach; ?>

               </table>

</body>

</html>

Взяли отсюда: https://docs.openathens.net/providers/apache-openid-connect-example 

На самом деле там может быть что угодно, просто этот код предоставляет некоторую отладочную информацию.

Сторонняя система

В качестве сторонней системы удобнее всего использовать OpenResty https://openresty.org/

В качестве входа ожидаем json, содержащий {'username': <собственно, имя пользователя> }, а в ответ отправим просто Allow или Deny.

В целях теста просто сделали список пользователей, которым разрешен доступ, разместили их в whitelisted_names.

Важно заметить, что парсер достаточно капризный, поэтому для отладки сохраняли тело запроса: access_log  /var/log/nginx/postdata.log  postdata

Наконец уговорили его отработать запрос =)

Конфигурация будет выглядеть так:

http {

server {

        listen *:8082;

        server_name _;

        location /reply/ {

                access_log  /var/log/nginx/postdata.log  postdata;

                error_log /var/log/nginx/error_openresty.log;

                content_by_lua_block {

                        whitelisted_names = {

                                'test',

                                'gimli',

                                'torin'

                        }

                        ngx.req.read_body()

                        local cjson = require "cjson"

                        local body = ngx.req.get_body_data()

                        local data = cjson.decode(body)

                        local username = data["username"]

                        flag = false

                        for index, value in ipairs(whitelisted_names) do

                                if value == username then

                                        flag = true

                                end

                        end

                        if flag then

                                ngx.say("Allow")

                        else

                                ngx.say("Deny")

                        end

}

}

http {

server {

        listen *:8082;

        server_name _;

        location /reply/ {

                access_log  /var/log/nginx/postdata.log  postdata;

                error_log /var/log/nginx/error_openresty.log;

                content_by_lua_block {

                        whitelisted_names = {

                                'test',

                                'gimli',

                                'torin'

                        }

                        ngx.req.read_body()

                        local cjson = require "cjson"

                        local body = ngx.req.get_body_data()

                        local data = cjson.decode(body)

                        local username = data["username"]

                        flag = false

                        for index, value in ipairs(whitelisted_names) do

                                if value == username then

                                        flag = true

                                end

                        end

                        if flag then

                                ngx.say("Allow")

                        else

                                ngx.say("Deny")

                        end

}

}

Работает =)

И наконец, сам Keycloak

Создаем клиент Apache2. Client id и Client Secret должны совпадать с тем, что прописано в конфиге Apache2

Создаем новый flow и в разделе Advanced присваиваем его клиенту

Проверяем, подключение проходит нормально.

Текст аутентификатора неполный – только те методы, которые мы модифицировали.

Создается класс TestAuthenticator3 на основе общего класса Authenticator. Основным методом аутентификатора является authenticate. Непосредственно в нём реализуется логика проверки, которую должен пройти пользователь при аутентификации. 

В нашем случае проверка основана на проверке наличия имени входящего пользователя в списке доверенных имен, хранимом на удаленном сервере. Для получения объекта пользователя, содержащего в том числе и имя, вызывается функция context.getUser(). Затем из полученного объекта выделяется имя с помощью функции getUsername(). Полученное имя отправляется на удаленный сервер, который проверяет его вхождение в список доверенных, и выдает результат.

В случае успешного прохождения проверки метод завершается вызовом функции context.success. Если же пользователь не прошёл проверку, то метод завершается вызовом функции context.failure, в которой дополнительно указывается тип ошибки аутентификации. Подробнее можно посмотреть в официальной документации: https://www.keycloak.org/docs/latest/server_development/#implementing-an-authenticator

Отладочная информация выводится в консоль. URL дополнительно ИС, к которой выполняется запрос, задан константой.

Аутентификатор:

public class TestAuthenticator3 implements Authenticator {

    private static final Logger logger = Logger.getLogger(croc.test.keycloak. authenticator. TestAuthenticator3.class);

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        var user=context.getUser();

        var username=user.getUsername()
        try {
            var res=sendPOST(username);
            System.out.println("Got response: " + res);
            if (res.contains("Allow")) {
                System.out.println("Response Allow");
                context.success();               
            }
            else {
                System.out.println("Response Deny – else path ");
                context.failure(AuthenticationFlowError.CLIENT_DISABLED);
            }
        }
        catch (Exception e){
            context.failure(AuthenticationFlowError.INTERNAL_ERROR);
            System.out.println("Exception path");           
        }
    }

private static final String USER_AGENT = "Mozilla/5.0";

private static final String POST_URL = "http://172.31.80.26:8082/reply/";

private static String sendPOST(String username) throws IOException {
    URL obj = new URL(POST_URL);
    HttpURLConnection con = (HttpURLConnection) obj.openConnection();
    con.setRequestMethod("POST");
    con.setRequestProperty("User-Agent", USER_AGENT);

    // POST
    con.setDoOutput(true);
    OutputStream os = con.getOutputStream();
    var request = "{\"username\":\"" + username + "\"}";
    os.write(request.getBytes());
    os.flush();
    os.close();
    // POST - END

    int responseCode = con.getResponseCode();
    System.out.println("POST Response Code :: " + responseCode);

    if (responseCode == HttpURLConnection.HTTP_OK) { //success
        BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
        String inputLine;
        StringBuffer response = new StringBuffer();

        while ((inputLine = in.readLine()) != null) {
            response.append(inputLine);
        }
        in.close();
        System.out.println("POST Response Text :: " + response.toString());
        return response.toString();
    }
    else {
        System.out.println("POST Response Text else path :: ");
        return "Error";
    }
}

}

AuthenticationFactory предназначен для генерации экземпляра аутентификатора и его взаимодействие с Keycloak. Фактически этот объект нужен, чтобы Keycloak увидел наш новый аутентификатор.

В данном классе определяются свойства, с которыми будет создан экземпляр нашего аутентификатора. Подробно о каждом из свойств можно посмотреть в официальной документации: https://www.keycloak.org/docs/latest/server_development/#implementing-an-authenticatorfactory

Фактори:

public class TestAuthenticator3Factory implements AuthenticatorFactory {

    public static final String ID = "TestAuthenticator 3";

    private static final Authenticator AUTHENTICATOR_INSTANCE = new TestAuthenticator3();
    static final String MESSAGE_CONFIG = "message_to_show_3";

    @Override
    public Authenticator create(KeycloakSession keycloakSession) {
        return AUTHENTICATOR_INSTANCE;
    }

    @Override
    public String getDisplayType() {
        return " TestAuthenticator3";
    }

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

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

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

    @Override
    public String getHelpText() {
        return "Test Authentication flow";
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        ProviderConfigProperty name = new ProviderConfigProperty();

        name.setType(STRING_TYPE);
        name.setName(MESSAGE_CONFIG);
        name.setLabel("Just empty label");
        name.setHelpText("Any help text");

        return Collections.singletonList(name);
    }

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

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

    @Override
    public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
    }

    @Override
    public void close() {
    }

    @Override
    public String getId() {
        return ID;
    }
}

Строим вот такой flow.

Вот что видим на Apache

Успешная попытка:

62.217.191.91 - gimli [14/Jun/2023:16:21:12 +0300] "GET /info2.php HTTP/1.1" 200 2462 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

62.217.191.91 - - [14/Jun/2023:16:21:26 +0300] "GET / HTTP/1.1" 302 1571 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

62.217.191.91 - gimli [14/Jun/2023:16:21:32 +0300] "GET /info.php?state=0CQZYZmW5jWpUlb4xbeqk2VcIs4&session_state=e4058c19-9a58-4e98-8d97-fbcb6966d0b8&code=9e76fced-42c9-454f-85d0-6d7a652da3e4.e4058c19-9a58-4e98-8d97-fbcb6966d0b8.098780c7-759e-41a3-96ce-d3837e1790a3 HTTP/1.1" 302 754 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

62.217.191.91 - gimli [14/Jun/2023:16:21:32 +0300] "GET / HTTP/1.1" 200 5381 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

62.217.191.91 - gimli [14/Jun/2023:16:21:32 +0300] "GET /index.files/image002.jpg HTTP/1.1" 200 47910 "http://askar.test.local/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

62.217.191.91 - gimli [14/Jun/2023:16:21:38 +0300] "GET /info2.php HTTP/1.1" 200 2360 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

Неуспешная попытка

2023-06-14 16:33:16,184 WARN  [org.keycloak.events] (executor-thread-98) type=LOGIN_ERROR, realmId=69bd9151-dd74-470a-ba27-1a660799fcf2, clientId=apache2, userId=null, ipAddress=62.217.191.91, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://askar.test.local/info.php, code_id=729202ce-a308-458c-bfe0-767fd9349d69, username=legolas, authSessionParentId=729202ce-a308-458c-bfe0-767fd9349d69, authSessionTabId=GQPjo8i5d6A

Вывод

Таким образом, мы добавили кастомный аутентификатор в KeyCloak, а также настроили веб‑сервер Apache и стороннюю систему аутентификации, и подружили ее с KeyCloak.