引言
最近在创建一个新的个人项目,根据当前的 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 接口。需要实现的方法有:
getAuthServiceName该方法需要返回插件名称;identityNames该方法需要返回一个Collection<String>,nacos会将这里面的字符作为key的字段注入到IdentityContext中供后续使用;enableAuth该方法入参ActionTypes、SignType返回布尔值,可在此判断是否对该类型的操作或模块进行鉴权;validateIdentity该方法入参IdentityContext、Resource返回布尔值,可在此对身份信息进行验证;validateAuthority该方法入参IdentityContext、Permission返回布尔值,可在此对权限进行验证,该方法在validateIdentity返回true时调用;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 下,并可以通过更改 nacos 的 nacos.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 错误,message 是 Forbidden。
再次查看服务配置的 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'}
...
可以看到 isLoginEnabled、enableAuth、validateIdentity 还有 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.AuthConfigs 将 nacos.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 配置项是 nacos 或 ldap 时才添加登录鉴定服务,如果是 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 接口。需要实现的方法有:
setServerList该方法入参List<String>是nacos服务地址,无需返回值,初始化时会调用;setNacosRestTemplate该方法入参NacosRestTemplate,无需返回值,初始化时会调用;login该方法入参Properties返回布尔值,登录接口;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 可以在服务端插件的 validateIdentity 和 validateAuthority 方法中获取到。那么服务端可以通过传入的值来判断是否返回 true 来进行 nacos 的服务注册鉴权。
结语
本文通过对 nacos 2.3.1 版本的服务端以及客户端鉴权插件的开发实践,发现了 nacos 的配置及文档中未明确或者描述不清晰的部分。总的来说就是 nacos 的 nacos.core.auth.system.type 配置项对于服务端插件开启登录页的影响,以及客户端插件的引入规则问题。本文是作者对于解决这一系列问题的自我思考和实践,网上关于这部分的参考资料也不是很多,实践过程中有哪些不符合相关配置或编码习惯的地方,欢迎提出一起探讨交流。