Всем привет.

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

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

В стандартном наборе представлено много типовых аутентификаторов. Но что делать, когда стандартных аутентификаторов недостаточно и необходимо реализовать свою логику? Официальная документация дает ответ: разработать аутентификатор самому.

Что мы вместе с нашим системным инженером направления кибербезопасности К2Тех Егором Туркиным в итоге и сделали.

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

Аутентификатор представляет собой 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, в которой дополнительно указывается тип ошибки аутентификации. Подробнее можно посмотреть в официальной документации

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

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

public class TestAuthenticator3 implements Authenticator {
    private static final Logger logger = Logger.getLogger(k2.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.

Комментарии (2)


  1. ifap
    07.09.2023 20:47
    +1

    Символично, что на заглавной иллюстрации изображен заклинивший цилиндр...


    1. asdobryakov Автор
      07.09.2023 20:47

      Спасибо что заметили, поправил