OpenID Connect

Дополнение Jmix OpenID Connect предоставляет предопределенные конфигурации Spring Security и набор сервисов, которые позволяют легко реализовать в приложении следующие возможности:

  • Аутентификация пользователя с использованием внешнего поставщика OpenID (например, Keycloak).

  • Маппинг атрибутов и ролей пользователя из поставщика OpenID на пользователя Jmix.

  • Сохранение сущности пользователя и назначений ролей после успешной аутентификации пользователя поставщиком OpenID.

Дополнение использует поддержку Spring Security для OAuth2 и OpenID Connect 1.0. Вы можете прочитать об этом в документации Spring Security.

Дополнение применяет конфигурацию OidcAutoConfiguration, если ее явно не отключить, установив свойство приложения jmix.oidc.use-default-configuration=false. Данная конфигурация включает аутентификацию OpenID Connect для URL пользовательского интерфейса и REST API.

Установка

Для автоматической установки через Jmix Marketplace следуйте инструкциям в разделе Add-ons.

Для ручной установки добавьте следующую зависимость в свой build.gradle:

implementation 'io.jmix.oidc:jmix-oidc-starter'

Конфигурация клиента

После включения дополнения в ваш проект и перед запуском приложения необходимо настроить "клиента". Клиент - это приложение Jmix, требующее аутентификации конечных пользователей поставщиком OpenID.

Для настройки клиента используется стандартный подход Spring Security. Например, это можно сделать, добавив следующие свойства в файл application.properties:

spring.security.oauth2.client.registration.keycloak.client-id=<client-id>
spring.security.oauth2.client.registration.keycloak.client-secret=<client-secret>
spring.security.oauth2.client.registration.keycloak.scope=openid, profile, email
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8180/realms/<realm>
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8180/realms/<realm>

keycloak в ключе свойства - это идентификатор провайдера. Он может иметь любое значение, например, okta, тогда свойство будет spring.security.oauth2.client.registration.okta.client-id.

Значения идентификатора клиента (Client ID) и секрета (Client Secret) должны быть взяты из поставщика OpenID.

Свойство issuer-uri содержит путь к поставщику OpenID Configuration Endpoint.

По умолчанию конфигурация дополнения будет использовать клэйм (claim) sub в качестве имени пользователя Jmix. Если вы хотите изменить это, используйте следующее свойство приложения:

spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username

Использование стандартной конфигурации дополнения

После включения дополнения в проект, настройки клиента и настройки Keycloak, приложение можно запустить. В этом случае стандартная конфигурация дополнения будет выполнять следующие действия:

  • Неаутентифицированные пользователи будут перенаправлены на страницу входа поставщика OpenID.

  • После того как пользователь пройдет аутентификацию поставщиком OpenID, будет создан экземпляр DefaultJmixOidcUser и установлен в контекст безопасности. Атрибуты пользователя не будут отображены. Пользователь не будет сохранен в базе данных.

  • Коллекция кодов ролей пользователя будет взята из клэйма "roles" токена и, затем, для каждой роли из значения клэйма, ресурсная роль и роль на уровне строк будут установлены в объекте аутентификации пользователя.

Класс DefaultJmixOidcUser реализует интерфейс JmixOidcUser. Класс пользователя должен всегда реализовывать этот интерфейс, потому что Jmix-приложения требуют интерфейс UserDetails, а Spring Security работает с интерфейсом OidcUser. JmixOidcUser просто расширяет оба эти интерфейса.

Сопоставление атрибутов и ролей пользователя

Если вам нужно работать с пользователем в памяти, но вы хотите заполнить некоторые атрибуты пользователя, создайте класс, который расширяет DefaultJmixOidcUser. В приведенном ниже примере у него есть атрибут position:

import io.jmix.oidc.user.DefaultJmixOidcUser;

public class MyUser extends DefaultJmixOidcUser {

    private String position;

    public String getPosition() {
        return position;
    }

    public void setPosition(String position) {
        this.position = position;
    }
}

Затем вам нужно зарегистрировать экземпляр OidcUserMapper как бин Spring. Вы можете расширить BaseOidcUserMapper и переопределить его методы:

import examples.oidcex1.entity.MyUser;
import io.jmix.oidc.claimsmapper.ClaimsRolesMapper;
import io.jmix.oidc.usermapper.BaseOidcUserMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Component;

import java.util.Collection;

@Component
public class MyOidcUserMapper extends BaseOidcUserMapper<MyUser> {

    private ClaimsRolesMapper claimsRolesMapper;

    public MyOidcUserMapper(ClaimsRolesMapper claimsRolesMapper) {
        this.claimsRolesMapper = claimsRolesMapper;
    }

    @Override
    protected MyUser initJmixUser(OidcUser oidcUser) {
        return new MyUser();
    }

    @Override
    protected void populateUserAttributes(OidcUser oidcUser, MyUser jmixUser) {
        jmixUser.setPosition((String) oidcUser.getClaims().get("position"));
    }

    @Override
    protected void populateUserAuthorities(OidcUser oidcUser, MyUser jmixUser) {
        Collection<? extends GrantedAuthority> authorities = claimsRolesMapper.toGrantedAuthorities(oidcUser.getClaims());
        jmixUser.setAuthorities(authorities);
    }
}

Обратите внимание, что в приведенном выше примере сопоставление клэймов из пользователя OIDC в Jmix granted authorities делегируется интерфейсу ClaimsRolesMapper. Реализация ClaimsRolesMapper по умолчанию - это DefaultClaimsRolesMapper, который получает клэйм с именем "roles" из токена ID. Этот клэйм должен содержать коллекцию имен ролей. Затем для каждой роли из значения клэйма будут искаться ресурсная роль и роль уровня строк в Jmix. Если они найдены, соответствующие granted authorities будут добавлены пользователю. Имя клэйма ролей можно настроить, используя следующее свойство приложения:

jmix.oidc.default-claims-roles-mapper.roles-claim-name=myRoles

При необходимости вы можете создать свой маппер клэймов в роли. Самый простой способ сделать это - расширить BaseClaimsRolesMapper и переопределить его методы getResourceRolesCodes() и/или getRowLevelRolesCodes(). Приведенный ниже пример демонстрирует, как назначать роли на основе клэйма "position":

import io.jmix.oidc.claimsmapper.BaseClaimsRolesMapper;
import io.jmix.security.role.ResourceRoleRepository;
import io.jmix.security.role.RoleGrantedAuthorityUtils;
import io.jmix.security.role.RowLevelRoleRepository;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.HashSet;
import java.util.Map;

@Component
public class MyClaimsRoleMapper extends BaseClaimsRolesMapper {

    public MyClaimsRoleMapper(ResourceRoleRepository resourceRoleRepository,
                              RowLevelRoleRepository rowLevelRoleRepository,
                              RoleGrantedAuthorityUtils roleGrantedAuthorityUtils) {
        super(resourceRoleRepository, rowLevelRoleRepository, roleGrantedAuthorityUtils);
    }

    @Override
    protected Collection<String> getResourceRolesCodes(Map<String, Object> claims) {
        Collection<String> jmixRoleCodes = new HashSet<>();
        String position = (String) claims.get("position");
        if ("Manager".equals(position)) {
            jmixRoleCodes.add("edit-contracts");
            jmixRoleCodes.add("view-archive");
        } else {
            jmixRoleCodes.add("view-contracts");
        }
        return jmixRoleCodes;

    }
}

Работа с JPA-сущностью пользователя

Для работы с дополнением Jmix OIDC, JPA-сущность User должна реализовывать интерфейс io.jmix.oidc.user.JmixOidcUser, который, в свою очередь, реализует org.springframework.security.oauth2.core.oidc.user.OidcUser, требуемый Spring Security.

Простейший способ сделать сущность User совместимой с дополнением OIDC - сделать этот класс подклассом абстрактного класса io.jmix.oidc.user.JmixOidcUserEntity:

@JmixEntity
@Entity
@Table(name = "USER_", indexes = {
        @Index(name = "IDX_USER__ON_USERNAME", columnList = "USERNAME", unique = true)
})
public class User extends JmixOidcUserEntity implements HasTimeZone {

    //...
}

Чтобы сохранять пользователей в базе данных после их входа с использованием поставщика OpenID, вам нужно зарегистрировать маппер пользователя, который расширяет класс SynchronizingOidcUserMapper. Этот суперкласс содержит поведение, которое сохраняет/обновляет пользователя в базе данных. При желании, вы также можете сохранять в базе данных и информацию о назначениях ролей.

import examples.oidcex1.entity.User;
import io.jmix.core.UnconstrainedDataManager;
import io.jmix.core.security.UserRepository;
import io.jmix.oidc.claimsmapper.ClaimsRolesMapper;
import io.jmix.oidc.usermapper.SynchronizingOidcUserMapper;
import io.jmix.security.role.RoleGrantedAuthorityUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Component;

@Component
public class MySynchronizingOidcUserMapper extends SynchronizingOidcUserMapper<User> {

    public MySynchronizingOidcUserMapper(UnconstrainedDataManager dataManager,
                                         UserRepository userRepository,
                                         ClaimsRolesMapper claimsRolesMapper,
                                         RoleGrantedAuthorityUtils roleGrantedAuthorityUtils) {
        super(dataManager, userRepository, claimsRolesMapper, roleGrantedAuthorityUtils);

        //store role assignments in the database (false by default)
        setSynchronizeRoleAssignments(true);
    }

    @Override
    protected Class<User> getApplicationUserClass() {
        return User.class;
    }

    @Override
    protected void populateUserAttributes(OidcUser oidcUser, User jmixUser) {
        jmixUser.setUsername(oidcUser.getName());
        jmixUser.setFirstName(oidcUser.getGivenName());
        jmixUser.setLastName(oidcUser.getFamilyName());
        jmixUser.setEmail(oidcUser.getEmail());
    }
}

Защита API

Приложение Jmix может работать как сервер ресурсов. Чтобы указать, какой авторизационный сервер использовать, определите следующее свойство приложения:

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8180/realms/<realm>

Значение свойства - это URL, содержащийся в клэйме iss для JWT-токенов, которые авторизационный сервер будет выдавать. См. документацию Spring Security для получения дополнительной информации.

По умолчанию значение клэйма sub используется в качестве имени пользователя Jmix, который устанавливается в контекст безопасности. Если вы хотите изменить это, используйте следующее свойство приложения:

jmix.oidc.jwt-authentication-converter.username-claim=preferred_username

В большинстве случаев значение свойства должно согласовываться со значением свойства spring.security.oauth2.client.provider.keycloak.user-name-attribute.

Токены доступа, полученные от поставщика OpenID, могут использоваться для доступа к защищенным конечным точкам, предоставляемым дополнением REST API.

Для локального экземпляра Keycloak токены доступа можно получить следующим образом:

curl -X POST http://localhost:8180/realms/sample1/protocol/openid-connect/token \
--user <client-id>:<client-secret> \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&scope=openid&username=<username>&password=<password>"

Например:

curl -X POST http://localhost:8180/realms/sample1/protocol/openid-connect/token \
--user jmix-app:UONXQZf6unxVuWsxXvhMAPv5IxFz5P7D \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&&scope=openid&username=johndoe&password=mypass"

Теперь рассмотрим, как защитить собственные контроллеры MVC, например следующий:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {

    @GetMapping("/authenticated/hello")
    public String authenticatedHello() {
        return "authenticated-hello";
    }

    @GetMapping("/anonymous/hi")
    public String anonymousHello() {
        return "anonymous-hi";
    }
}

Представьте, что вам нужно защитить все URL, начинающиеся с /authenticated/, и сделать все URL, начинающиеся с /anonymous/, доступными для анонимного доступа. Для достижения этого определите бин AuthorizedUrlsProvider в вашем основном классе приложения или в любом классе конфигурации Spring:

@Bean
public AuthorizedUrlsProvider myAuthorizedUrlsProvider() {
    return new AuthorizedUrlsProvider() {
        @Override
        public Collection<String> getAuthenticatedUrlPatterns() {
            return Arrays.asList("/authenticated/**");
        }

        @Override
        public Collection<String> getAnonymousUrlPatterns() {
            return Arrays.asList("/anonymous/**");
        }
    };
}

Свойства OIDC

jmix.oidc.use-default-configuration

Определяет, применять ли конфигурацию по умолчанию. По умолчанию установлено значение true. Установите это свойство в false, если вы хотите иметь доступ к бинам и интерфейсам дополнения, но не хотите использовать предварительно определенную конфигурацию Spring Security для защиты конечных точек. В этом случае вам придется написать свою собственную конфигурацию безопасности.

jmix.oidc.use-default-configuration = false

jmix.oidc.default-claims-roles-mapper.roles-claim-name

Определяет имя клэйма в токене ID, которое содержит коллекцию названий ролей. Это свойство используется классом DefaultClaimsRolesMapper. Значение по умолчанию - roles.

jmix.oidc.default-claims-roles-mapper.roles-claim-name = myRoles

Настройка локального экземпляра Keycloak

Один из самых популярных поставщиков OpenID - Keycloak. Для ознакомления с дополнением Jmix OIDC вы можете запустить Keycloak локально с помощью Docker.

Запуск Keycloak с использованием Docker

Используйте следующую команду для запуска экземпляра Keycloak с помощью Docker на порту 8180:

docker run -p 8180:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin --name keycloak quay.io/keycloak/keycloak:22.0 start-dev

См. документацию Keycloak для получения дополнительной информации.

URL Keycloak: http://localhost:8180

Административные учетные данные:

Имя пользователя: admin
Пароль: admin

Вы можете прочитать о настройке экземпляра Keycloak в Руководстве администратора сервера.

Создание Realm

Войдите в консоль администратора Keycloak.

Откройте выпадающее окно вверху левой панели.

Нажмите Create Realm.

create realm 1

Дайте имя новому Realm, например, "sample".

create realm 2

Создание клиента

Чтобы подключить приложение Jmix к Keycloak, вам нужно создать нового клиента jmix-app с типом клиента OpenID Connect.

create client 1

Включите Client authentication.

create client 2

Введите Valid redirect URIs:

http://localhost:8080/*

и Web origins:

http://localhost:8080
create client 3

Откройте только что созданный клиент и перейдите на вкладку Credentials. Там отображается Client secret, который вам понадобится в проекте Jmix для настройки подключения.

client credentials

Параметры клиента должны использоваться в файле application.properties. См. раздел Настройка клиента.

Создание роли

Затем вам следует создать новую роль в Realm. По умолчанию имя роли должно совпадать с кодом роли Jmix. Создайте роль system-full-access:

create role

Создание пользователя

Создайте пользователя с именем пользователя johndoe:

create user

После сохранения пользователя появится вкладка Credentials. Там вы можете установить пароль пользователя.

create user credentials

На вкладке Role mappings назначьте роль system-full-access:

assign role

Если вы хотите заполнить атрибуты пользователя (например, "должность"), то можете сделать это на вкладке Attributes в редакторе пользователя.

Создание маппера ролей

Чтобы вернуть информацию о ролях Realm в токене ID, вам нужно определить маппер для клиента jmix-app. Откройте редактор клиента и перейдите на вкладку Client scopes:

create mapper 1

Откройте редактор области jmix-app-dedicated. Добавьте предопределенный маппер для "realm roles":

create mapper 2

Откройте только что созданный маппер "realm roles" Измените значение атрибута Token Claim Name на roles и выберите флажок Add to userinfo. Таким образом вы указываете, что клэйм, содержащий список ролей пользователя, будет называться roles.

create mapper 3