О Keycloak

2019-01-06

Есть такая штука. Называется Keycloak. Это не то, что вы подумали, а плащ или мантия, типа для ключей, или «ключевая мантия». Это сервер для Single-Sing-On (SSO), и для хранения учётных записей, и для всего такого, связанного с аутентификацией и авторизацией. Это — часть JBoss, который, оказывается, теперь называется WildFly, сервера приложений (Java EE) от RedHat. Кажется, это самый популярный среди свободных сервер SSO. Потому что на нас одновременно свалилось аж два с половиной проекта, где для авторизации юзеров используется именно Keycloak.

Keycloak old logo

Keycloak умеет OpenID Connect (aka OIDC) и SAML (aka Security Assertion Markup Language).

SAML — это привет из начала двухтысячных на считающимся ныне монструозным XML. Близкий друг действительно монструозного SOAP. Но теперь строить API на XML не модно. Поэтому забудем про SAML.

OpenID Connect будет поинтереснее. Он не имеет ничего общего с протоколами OpenID версий 1.1 и 2.0. Точнее, если верить Википедии, это следующая версия OpenID. И построено оно поверх OAuth 2.0.

OpenID Connect — это конкретный протокол аутентификации, построенный поверх фреймворка авторизации OAuth 2.0.

Помните, OAuth 2.0 — это лишь фреймворк. Там не прописано чётко, какими должны быть токены, как именно их получать, и прочие мелкие детали. Вот OpenID Connect эти детали конкретизирует. И у нас даже есть конкретная реализация: Keycloak.

OpenID Connect

Keycloak сервер — весь такой multitenancy. На нём положено создавать Realms — независимые пространства со своими юзерами, группами, ролями, ключами, страницами авторизации и клиентами. Клиентами (clients) в смысле OAuth, со своим ClientID и Redirect URI.

Если у нас есть Realm и мы знаем, где находится Keycloak, можно попробовать поавторизоваться. Есть у нас чётко определённый набор Endpointов, с которых можно начать. И их список тоже можно получить.

$ curl https://keycloak.example/auth/realms/gelin/.well-known/openid-configuration | jq .
{
  "issuer": "https://keycloak.example/auth/realms/gelin",
  "authorization_endpoint": "https://keycloak.example/auth/realms/gelin/protocol/openid-connect/auth",
  "token_endpoint": "https://keycloak.example/auth/realms/gelin/protocol/openid-connect/token",
  "token_introspection_endpoint": "https://keycloak.example/auth/realms/gelin/protocol/openid-connect/token/introspect",
  "userinfo_endpoint": "https://keycloak.example/auth/realms/gelin/protocol/openid-connect/userinfo",
  "end_session_endpoint": "https://keycloak.example/auth/realms/gelin/protocol/openid-connect/logout",
  "jwks_uri": "https://keycloak.example/auth/realms/gelin/protocol/openid-connect/certs",
...

Как положено в OAuth, попробуем получить токен.

$ curl -v \
  --url https://keycloak.example/auth/realms/gelin/protocol/openid-connect/auth \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data 'response_type=code&scope=openid&client_id=test&redirect_uri=http%3A%2F%2Flocalhost%3A8080'

В ответ нам выдадут форму логина, которая ставит кучку куки. В общем, логично.

Authorization code flow

Можно попробовать Implicit Flow.

$ curl -v \
  --url https://keycloak.example/auth/realms/gelin/protocol/openid-connect/auth \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data 'response_type=token&scope=openid&client_id=test&redirect_uri=http%3A%2F%2Flocalhost%3A8080&nonce=123'

В ответ будет подобная же форма логина.

Implicit flow

Ну типа почти обычный OAuth 2.0. Только нам точно известно, куда ходить за логином, где получать токен, где спрашивать сведения о пользователе.

Более того, токены у нас подписаны RSA ключом. И мы можем получить публичный ключ для их проверки.

$ curl https://keycloak.example/auth/realms/gelin/protocol/openid-connect/certs | jq .
{
  "keys": [
    {
      "kid": "AOaPQjC_jwnQG3tx4dqQYSKZyfgSlMLOjy_KFjikvcw",
      "kty": "RSA",
      "alg": "RS256",
      "use": "sig",
      "n": "j11PAJZ36mh0q1Inn1CsJZ7V_KckqZgFxzPgysrODl9P1k2-yNjpCWzY4UFJlqRalbYG9eLmb5gZAz_8R95BGj8C3u9vFOJYpWhj2PdHgb59KUPHvvn6CLJW_V1xac8uUcDlGnpxbFsztE19qlTFCeFOBhhdzHQW5tHtZhZZvdp6GVrCa9-CTKoP4LjRW7phJk-V-t93AkXWyXufXGzbpQzu4chcsfmgBDGaoDBm1_PomB_d6orOURdAIZi_-rkkcUz9Zz4e_Seo-RxeQ_p4LvWzmTFsPM4o3argnN7TYmT7G8iXeNw3pFQ0O-SUgz070-Ph0gl2mcWso7Pwya-MDQ",
      "e": "AQAB"
    }
  ]
}

В OpenID Connect, как положено, имеется Access Token, который нужно включать в заголовок Authorization, чтобы получить те же сведения о пользователе, и Refresh Token, который нужен для обновления Access Token. А ещё есть ID Token.

Именно ID Token является JWT токеном, который можно проверить публичным RSA ключом. И именно там содержатся некоторые сведения о пользователе. Можно не делать дополнительных запросов на Keycloak, а сразу извлечь всё, что надо, из ID Token.

На самом деле, все эти curlы вам не понадобятся. Ибо политикой Keycloak является использование адаптеров. То есть библиотек для ваших любимых языков программирования или middleware для ваших любимых веб фреймворков. Берёте нужный адаптер, настраиваете его JSON файлом, который можно скачать прямо в настройках клиента в админке Keycloak, и пользуетесь.

Если адаптера для Keycloak не нашлось, вполне может сгодиться адаптер для просто OpenID Connect. Их много, для разных языков (например, для Go).

Если у вас, допустим, есть какой-то UI на React, то вам достаточно подключить адаптер, и позаботиться о логине в нужный момент, и обновлении токенов, например, по таймеру.

import Keycloak from 'keycloak-js';

export const kc = Keycloak({
  url: process.env.REACT_APP_KEY_CLOAK_SERVER_URL,
  realm: process.env.REACT_APP_KEY_CLOAK_REALM,
  clientId: process.env.REACT_APP_KEY_CLOAK_CLIENT_ID,
});

const updateLocalStorage = () => {
  localStorage.setItem('kc_token', kc.token);
  localStorage.setItem('kc_refreshToken', kc.refreshToken);
};

kc.init({onLoad: 'login-required'})
  .success((authenticated) => {
    if (authenticated) {
      updateLocalStorage();

      setInterval(() => {
        kc.updateToken(11)
          .success((refreshed) => {
            if (refreshed) {
              updateLocalStorage();
            } else {
            }
          })
          .error(() => kc.logout());
      }, 10000);

      ReactDOM.render(app, document.getElementById('root'));
    } else {
      kc.login();
    }
  })
  .error(() => {
    kc.login();
  });

kc.onTokenExpired = () => {
  kc.logout();
  console.log('Token Expired');
};

Потом этот токен из Keycloak можно вставлять в заголовки запросов к API. А API уже будет проверять валидность этого токена.

Если взять адаптер для Spring Boot, то достаточно его подключить:

implementation 'org.keycloak:keycloak-spring-boot-2-starter:4.0.0.Final'

И настроить:

keycloak.realm: gelin
keycloak.resource: test
keycloak.auth-server-url: https://keycloak.example/auth
keycloak.public-client: true

keycloak.securityConstraints[0].authRoles[0]: user
keycloak.securityConstraints[0].authRoles[1]: admin
keycloak.securityConstraints[0].securityCollections[0].name: api resource
keycloak.securityConstraints[0].securityCollections[0].patterns[0]: /api

И всё. Эта магия работает.

Не нравится мне, когда такие довольно сложные для понимания штуки, как всякие токены, где, чтобы разобраться во всём, нужно нарисовать не одну диаграмму последовательностей, полностью закрываются очень простым фасадом. А если что-нибудь сломается, как выяснять, в чём проблема?