关于 nacos 的服务端与客户端鉴权插件开发

910 阅读9分钟

引言

最近在创建一个新的个人项目,根据当前的 spring cloud 生态,服务注册中心选择了 nacos,版本选择了 2.3.1 版本,之前做的项目使用过 nacos 的 1.3.0 版本。对比发现两者之间存在一些区别,其中最直观的是,1.3.0 版本中的 nacos 登录页面,在 2.3.1 版本中默认不显示了,经过文档阅读,发现其中一句话:2.2.2版本之前的Nacos默认控制台,无论服务端是否开启鉴权,都会存在一个登录页;这导致很多用户被误导认为Nacos默认是存在鉴权的。,意味着 2.3.1 版本中,默认不显示登录页面,需要手动开启。在开启登录页的过程中,看到了文档中写的 nacos 的服务端与客户端鉴权插件开发,但在实操过程中发现具体实施和文档中的描述有些出入,所以决定自己写一篇文章记录下来,方便交流讨论。

服务端插件

首先看看 nacos 文档中对于服务端插件的描述:

根据文档描述,插件以 SPI 的方式进行扩展,需要先实现 com.alibaba.nacos.plugin.auth.spi.server.AuthPluginService 接口。需要实现的方法有:

  1. getAuthServiceName 该方法需要返回插件名称;
  2. identityNames 该方法需要返回一个 Collection<String>nacos 会将这里面的字符作为 key 的字段注入到 IdentityContext 中供后续使用;
  3. enableAuth 该方法入参 ActionTypesSignType 返回布尔值,可在此判断是否对该类型的操作或模块进行鉴权;
  4. validateIdentity 该方法入参 IdentityContextResource 返回布尔值,可在此对身份信息进行验证;
  5. validateAuthority 该方法入参 IdentityContextPermission 返回布尔值,可在此对权限进行验证,该方法在 validateIdentity 返回 true 时调用;
  6. isLoginEnabled 该方法返回布尔值,可在此控制该插件是否开启开源控制台登录页; 自然地,通过文档描述,可以编写出一个简单的服务端插件实现,如下所示:
public class NacosAuthPuginService implements AuthPluginService {
    private Logger INFO = LoggerFactory.getLogger("com.test.nacosAuth.plugin");
    @Override
    public Collection<String> identityNames() {
        List<String> IDENTITY_ATTRS = new ArrayList<String>();
        IDENTITY_ATTRS.add("username");
        IDENTITY_ATTRS.add("password");
        return IDENTITY_ATTRS;
    }
    @Override
    public boolean enableAuth(ActionTypes type, String s) {
        INFO.info("===> enableAuth");
        return true;
    }
    @Override
    public boolean validateIdentity(IdentityContext ic, Resource r) throws AccessException {
        INFO.info("===> validateIdentity" + ic.getParameter("username", "test") + r.toString());
        return true;
    }
    @Override
    public Boolean validateAuthority(IdentityContext ic, Permission p) throws AccessException {
        INFO.info("===> validateAuthority" + ic.getParameter("username", "test") + p.toString());
        return true;
    }
    @Override
    public String getAuthServiceName() {
        INFO.info("===> getAuthServiceName");
        return "MyPlugin";
    }
    @Override
    public boolean isLoginEnabled() {
        INFO.info("===> isLoginEnabled");
        return true;
    }
}

随后,根据文档描述,可以将打包好的 jar 包放至服务端的 classpath${nacos-server.path}/plugins 下,并可以通过更改 nacosnacos.core.auth.system.type 配置项来启用插件:

转存失败,建议直接上传图片文件

根据文档将 nacos.core.auth.system.type 配置为 MyPlugin 后,启动 nacos 服务并进入服务管理页面,观察 logger 的打印信息:

...
INFO ===> getAuthServiceName
INFO ===> getAuthServiceName
INFO ===> getAuthServiceName
INFO [AuthPluginManager] Load AuthPluginService(class com.test.nacosAuth.plugin.NacosAuthPuginService) AuthServiceName(MyPlugin) successfully.
INFO [AuthPluginManager] Load AuthPluginService(class com.alibaba.nacos.plugin.auth.impl.NacosAuthPluginService) AuthServiceName(nacos) successfully.
INFO [AuthPluginManager] Load AuthPluginService(class com.alibaba.nacos.plugin.auth.impl.LdapAuthPluginService) AuthServiceName(ldap) successfully.
INFO ===> isLoginEnabled
...

通过日志可以看到,已经成功加载了 MyPlugin 插件,并打印了 isLoginEnabled 方法,说明的插件已经生效。由于插件的 isLoginEnabled 方法返回的是 true,所以需要登录才能进入服务管理页面。但当尝试登录时,会发现不管输入什么用户名密码,都会返回 403 错误,messageForbidden

再次查看服务配置的 application.properties 文件,可以看到关于 nacos.core.auth.system.type 的配置:

### The auth system to use, currently only 'nacos' and 'ldap' is supported:
nacos.core.auth.system.type=MyPlugin

### If turn on auth system:
nacos.core.auth.enabled=true

上面的备注信息是 当前只支持 'nacos' 和 'ldap',这里可以看到和文档描述有出入,文档描述的是可以配置 getAuthServiceName 的返回值。如果根据文档描述进行配置,那么开启登录页后将无法登录,那如果将 isLoginEnabled 方法的返回值改为 false 从而不开启登录页会如何呢?再次打包后观察日志信息:

...
INFO [AuthPluginManager] Load AuthPluginService(class com.tingtong.nacosAuth.NacosAuthPuginService) AuthServiceName(MyPlugin) successfully.
...
INFO ===> isLoginEnabled
...
INFO ===> enableAuth
INFO ===> validateIdentitytestResource{namespaceId='', group='', name='', type='config', properties={action=r}}
NFO ===> validateAuthoritytestPermission{resource='Resource{namespaceId='', group='', name='', type='config', properties={action=r}}', action='r'}
...

可以看到 isLoginEnabledenableAuthvalidateIdentity 还有 validateAuthority 均被依次调用,表明插件已经生效。那么可以说明按文档配置 nacos.core.auth.system.type 在不开启登录页的情况下是有效的。

如果按application.properties 文件的描述,将 nacos.core.auth.system.type 配置为 nacos 后启动服务,在登录页输入用户名 nacos,密码 nacos 后登录成功,但显然也不会打印 MyPlugin 里的日志信息,因为并没有加载 MyPlugin 插件。

那么为什么自定义插件开启登录页后,不管输入什么都会返回 403 错误呢?有没有什么方式既能开启登录页,又可以运行自定义插件呢?要解决这两个问题就需要进入源码来分析了。通过分析 nacos.core.auth.system.type 的使用位置来确定问题所在。首先 nacos.core.auth.system.type 字符串在 com.alibaba.nacos.plugin.auth.constant.Constants 中赋给了常量 NACOS_CORE_AUTH_SYSTEM_TYPE,然后在 com.alibaba.nacos.auth.config.AuthConfigsnacos.core.auth.system.type 配置项赋给了 nacosAuthSystemType 私有量,最后在 com.alibaba.nacos.plugin.auth.impl.NacosAuthConfig 中发现这么一段代码:

@Bean
public GlobalAuthenticationConfigurerAdapter authenticationConfigurer() {
    return new GlobalAuthenticationConfigurerAdapter() {
        @Override
        public void init(AuthenticationManagerBuilder auth) throws Exception {
            if (AuthSystemTypes.NACOS.name().equalsIgnoreCase(authConfigs.getNacosAuthSystemType())) {
                auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
            } else if (AuthSystemTypes.LDAP.name().equalsIgnoreCase(authConfigs.getNacosAuthSystemType())) {
                auth.authenticationProvider(ldapAuthenticationProvider);
            }
        }
    };
}

这里可以看到,只有当 nacos.core.auth.system.type 配置项是 nacosldap 时才添加登录鉴定服务,如果是 MyPlugin 则不会添加登录鉴定服务,所以配置 MyPlugin 后登录报 403 错误。在加载插件服务的位置 com.alibaba.nacos.plugin.auth.spi.server.AuthPluginManager,有如下代码:

private final Map<String, AuthPluginService> authServiceMap = new HashMap<>();
private void initAuthServices() {
    Collection<AuthPluginService> authPluginServices = NacosServiceLoader.load(AuthPluginService.class);
    for (AuthPluginService each : authPluginServices) {
        if (StringUtils.isEmpty(each.getAuthServiceName())) {
            LOGGER.warn(
                    "[AuthPluginManager] Load AuthPluginService({}) AuthServiceName(null/empty) fail. Please Add AuthServiceName to resolve.",
                    each.getClass());
            continue;
        }
        authServiceMap.put(each.getAuthServiceName(), each);
        LOGGER.info("[AuthPluginManager] Load AuthPluginService({}) AuthServiceName({}) successfully.",
                each.getClass(), each.getAuthServiceName());
    }
}

这里会将 getAuthServiceName 返回的值作为 key 添加到一个 HashMap 中。结合这两段源码,就能解决前文提到的两个问题。在加载登录鉴定服务时会忽略 nacos.core.auth.system.type 配置内容的大小写,而 authServiceMap 中的哈希键是区分大小写的,那么将自定义插件中 getAuthServiceName 的返回值和 nacos.core.auth.system.type 配置项均设为 NACOS 就可以达到既能开启登录页登录,又能使用自定义插件功能的目的了。将修改后的插件打包后启动服务,可以在登录页登录,并能看到以下日志:

...
INFO [AuthPluginManager] Load AuthPluginService(class com.tingtong.nacosAuth.NacosAuthPuginService) AuthServiceName(NACOS) successfully.
...
INFO ===> isLoginEnabled
...
INFO ===> enableAuth
INFO ===> validateIdentitytestResource{namespaceId='', group='', name='', type='config', properties={action=r}}
NFO ===> validateAuthoritytestPermission{resource='Resource{namespaceId='', group='', name='', type='config', properties={action=r}}', action='r'}
...

至此,自定义服务端插件即可正常使用。对于 nacos.core.auth.system.type 配置项的使用不知道是我的理解问题还是我的代码编写问题还是说 nacos 本身存在的问题,导致无法做到既能随意定义配置值,又能做到正常登录页登录。如果可以将这个配置项拆成两个,一个选择登陆服务的方式,一个选择自定义插件的名称,那就更加清晰了,或者在文档上更加详尽地说明这一问题,以及解决方式也未尝不可。

客户端插件

同样地,看看 nacos 文档中对于客户端插件的描述:

转存失败,建议直接上传图片文件

根据文档描述,插件以 SPI 的方式进行扩展,那么就需要先实现 com.alibaba.nacos.plugin.auth.spi.client.ClientAuthService 接口。需要实现的方法有:

  1. setServerList 该方法入参 List<String>nacos 服务地址,无需返回值,初始化时会调用;
  2. setNacosRestTemplate 该方法入参 NacosRestTemplate,无需返回值,初始化时会调用;
  3. login 该方法入参 Properties 返回布尔值,登录接口;
  4. getLoginIdentityContext 该方法入参 Resource  返回 IdentityContext,获取经过登录接口转换后的身份信息; 自然地,通过文档描述,可以得到以下代码:
public class ClientAuthPluginService implements ClientAuthService {
    private Logger INFO = LoggerFactory.getLogger("com.test.clientAuth.plugin");
    @Override
    public void setServerList(List<String> list) {
        INFO.info("ClientAuthPluginService setServerList" + list.toString());
    }
    @Override
    public void setNacosRestTemplate(NacosRestTemplate nacosRestTemplate) {
        INFO.info("ClientAuthPluginService setNacosRestTemplate");
    }
    @Override
    public Boolean login(Properties properties) {
        INFO.info("ClientAuthPluginService login");
        return true;
    }
    @Override
    public LoginIdentityContext getLoginIdentityContext(RequestResource resource) {
        LoginIdentityContext ic = new LoginIdentityContext();
        ic.setParameter("username", "admin");
        ic.setParameter("password", "123456");
        return ic;
    }
    @Override
    public void shutdown() throws NacosException { // 该方法文档中未提及,但接口 ClientAuthService 继承的 Closeable 接口需要实现这一方法
        INFO.info("ClientAuthPluginService shutdown");
    }
}

将上述代码打包成 jar 包后,放入项目的 classpath(即项目的 /resources )中,但却并不生效。打印的关于 ClientAuthService 的信息为:

[ClientAuthPluginManager] Load ClientAuthService com.alibaba.nacos.client.auth.impl.NacosClientAuthServiceImpl success.
[ClientAuthPluginManager] Load ClientAuthService com.alibaba.nacos.client.auth.ram.RamClientAuthServiceImpl success.

这里尝试了很多次,自定义的客户端插件打包的 jar 包并不能加载到。随后便尝试将插件以 SPI 的方式直接注入到项目中,依旧未打印加载成功的日志。此时又去查看源码,在 com.alibaba.nacos.plugin.auth.spi.client.ClientAuthPluginManager 中发现以下代码:

/**
 * init ClientAuthService.
 */
public void init(List<String> serverList, NacosRestTemplate nacosRestTemplate) {
    Collection<AbstractClientAuthService> clientAuthServices = NacosServiceLoader
            .load(AbstractClientAuthService.class);
    for (ClientAuthService clientAuthService : clientAuthServices) {
        clientAuthService.setServerList(serverList);
        clientAuthService.setNacosRestTemplate(nacosRestTemplate);
        clientAuthServiceHashSet.add(clientAuthService);
        LOGGER.info("[ClientAuthPluginManager] Load ClientAuthService {} success.",
                clientAuthService.getClass().getCanonicalName());
    }
    if (clientAuthServiceHashSet.isEmpty()) {
        LOGGER.warn("[ClientAuthPluginManager] Load ClientAuthService fail, No ClientAuthService implements");
    }
}

可以看到这里 load 的是 AbstractClientAuthService 类,查看文档,有这一句描述 您也可以选择继承com.alibaba.nacos.plugin.auth.spi.client.AbstractClientAuthService,该父类默认实现了setServerList和setNacosRestTemplate。 所以尝试直接继承 AbstractClientAuthService 再通过 SPI 的方式注入,再次查看日志有:

[ClientAuthPluginManager] Load ClientAuthService com.testauth.springcloud.ClientAuthPlugin success.
[ClientAuthPluginManager] Load ClientAuthService com.alibaba.nacos.client.auth.impl.NacosClientAuthServiceImpl success.
[ClientAuthPluginManager] Load ClientAuthService com.alibaba.nacos.client.auth.ram.RamClientAuthServiceImpl success.

这里能正常打印加载的自定义插件的日志。同时,在带有自定义服务端插件的 nacos 服务端,有如下日志打印:

INFO ===> validateIdentityadminResource{namespaceId='public', group='DEFAULT_GROUP', name='', type='naming', properties={requestClass=ServiceListRequest, action=r}}
INFO ===> validateAuthorityadminPermission{resource='Resource{namespaceId='public', group='DEFAULT_GROUP', name='', type='naming', properties={requestClass=ServiceListRequest, action=r}}', action='r'}

可以看到在客户端插件的 getLoginIdentityContext 中在 LoginIdentityContext 中添加的 username 可以在服务端插件的 validateIdentityvalidateAuthority 方法中获取到。那么服务端可以通过传入的值来判断是否返回 true 来进行 nacos 的服务注册鉴权。

结语

本文通过对 nacos 2.3.1 版本的服务端以及客户端鉴权插件的开发实践,发现了 nacos 的配置及文档中未明确或者描述不清晰的部分。总的来说就是 nacosnacos.core.auth.system.type 配置项对于服务端插件开启登录页的影响,以及客户端插件的引入规则问题。本文是作者对于解决这一系列问题的自我思考和实践,网上关于这部分的参考资料也不是很多,实践过程中有哪些不符合相关配置或编码习惯的地方,欢迎提出一起探讨交流。