OpenID 连接

Jmix OpenID Connect(OIDC)扩展组件提供了预定义 Spring Security 配置和一组服务,用于支持以下功能:

  • 通过外部 OpenID 供应商进行用户认证(例如,Keycloak)。

  • 将 OpenID 供应商中用户的属性和角色映射至 Jmix 用户。

  • OpenID 供应商成功完成用户认证之后,持久化用户实体和用户角色表。

扩展组件通过 Spring Security 支持 OAuth2 和 OpenID Connect 1.0。请参阅 Spring Security 文档

扩展组件会使用 OidcAutoConfiguration 配置,可以通过 jmix.oidc.use-default-configuration=false 应用程序属性禁用。这个配置为 UI 和 REST API URL 启用 OIDC 认证。

安装

按照 扩展组件 章节的说明进行安装。

手动安装,需在 build.gradle 文件添加下列依赖:

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

客户端配置

为项目添加了依赖之后,需要先配置 “client”,这里的 client(客户端)指的是一个需要向 OpenID 供应商请求用户认证的 Jmix 应用程序。

可以使用标准的 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 表示供应商的 ID。可以是任意值,例如, 对于 okta,这个属性就是 spring.security.oauth2.client.registration.okta.client-id

Client ID 和 client secret 的值必须从 OpenID 供应商获取。

issuer-uri 属性配置 OpenID 供应商 配置端点 的路径。

默认情况下,使用 "sub" claim 作为 Jmix 用户的用户名。如果需要更改,可以通过下面配置实现:

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

使用默认组件配置

在项目中引入组件且配置客户端和 设置 Keycloak 后,就可以启动应用程序了。此时,组件中默认的配置将执行以下操作:

  • 未经认证的用户将被重定向到 OpenID 供应商登录页面。

  • 用户在 OpenID 供应商中通过认证后,将创建一个 DefaultJmixOidcUser 实例并将其设置为安全上下文。此时不会映射任何用户属性。用户也不会存储在数据库中。

  • 用户的所有角色代码将从 ID Token 的 “roles” claim 中获取,然后将这些代码对应的资源和行级角色设置到用户认证对象中。

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 bean。这里可以继承 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 用户的 claim 映射至 Jmix 权限的过程委托给了 ClaimsRolesMapper 接口。ClaimsRolesMapper 的默认实现是 DefaultClaimsRolesMapper,从 ID Token 中获取名为 “roles” 的 claim。这个 claim 必须包含角色名称的集合。然后,对于 claim 值中的每个角色,会在 Jmix 中搜索对应的资源角色和行级角色,如果找到,则分配给用户。可以使用下面的应用程序属性配置角色 claim 的名称:

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

如果需要,也可以创建自定义的 claim 与角色的 mapper。最简单的方式就是继承 BaseClaimsRolesMapper 并重写其中的 getResourceRolesCodes() 和/或 getRowLevelRolesCodes() 方法。下面的示例演示了如何根据 “position” claim 分配角色:

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;

    }
}

使用 User JPA 实体

Jmix OIDC 组件能正常使用还需要 User JPA 实体实现 io.jmix.oidc.user.JmixOidcUser 接口。这个接口继承了 Spring Security 需要的 org.springframework.security.oauth2.core.oidc.user.OidcUser 接口。

因此,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 类的用户 mapper。此父类包含在数据库中存储/更新用户的行为。此外,还可以在数据库中存储角色分配的信息。

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>

该属性的值是授权服务器将要颁发 JWT token 的 “iss” claim 中包含的 URL。参阅 Spring Security 文档 了解更多信息。

默认情况下,使用 "sub" claim 的值作为 Jmix 用户的用户名并设置到安全上下文。如果需要更改,可以通过下面配置实现:

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

大多数情况下,这个配置的值应当与 spring.security.oauth2.client.provider.keycloak.user-name-attribute 配置的值相同。

从 OpenID 供应商获取的 access token 可用于访问 REST API 扩展组件提供的受保护端点。

对于本地 Keycloak 实例,可以通过以下方式获取 access token:

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

假设要保护所有以 /authenticated/ 开头的 URL,而所有以 /anonymous/ 开头的 URL 都可供匿名访问。为此,需要在应用程序主类或任何 Spring 配置类中定义一个 AuthorizedUrlsProvider bean:

@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。如果希望访问扩展组件中的 Bean 和接口,但不想使用预定义的 Spring security 配置来保护端点,请将该属性设置为 false。此时,必须编写自定义的安全配置。

jmix.oidc.use-default-configuration = false

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

定义 ID Token 中包含角色名称集合的 claim 名称。该属性由 DefaultClaimsRolesMapper 使用,默认值为 roles

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

配置本地 Keycloak 实例

最流行的 OpenID 供应商是 Keycloak。为了熟悉 Jmix OIDC 扩展组件,可以在本地通过 Docker 运行 Keycloak。

使用 Docker 启动 Keycloak

通过下面的 Docker 命令在 8180 端口启动 Keycloak 实例:

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 文档

Keycloak URL: http://localhost:8180

管理员账号:

Username: admin
Password: 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

如果需要输入用户的其他属性(例如,“position”),可以在用户编辑器的 Attributes 标签页输入。

创建角色映射器

为了在 ID Token 中返回 realm 的角色信息,需要为 jmix-app 客户端定义一个 mapper。打开客户端编辑器并切换至 Client scopes 标签页:

create mapper 1

打开 jmix-app-dedicated 的编辑器。添加 “realm roles” 的预定义 mapper:

create mapper 2

打开新创建的 realm roles mapper。Token Claim Name 属性设置为 roles。这样我们显式地设置了包含角色的 token claim 的名称为 roles

选中 Add to userinfo 复选框。

create mapper 3