[]
至此,您应该已经对活字格安全提供程序有了大概的了解,并且也安装创建了一个最简单的安全提供程序集。
那么,从本章开始,您将可以完整地了解安全提供程序的核心接口、数据结构、认证模式等主要的核心开发概念,有了这些概念,就可以满足几乎所有的第三方用户的集成。
通过本节内容,您将可以了解到:
ISecurityProvider 核心接口
ISupportSettings 认证参数配置化接口
认证模式相关接口
其他可选接口
ISecurityProvider 是安全提供程序的核心接口,活字格服务器会通过此接口读取第三方用户信息、校验第三方用户登录等。
目前,此接口共有 5 个属性, 1 个方法需要您实现。接下来,将为您分属性、方法介绍此接口。
Name属性用以指定安全提供程序的名称,主要用于在活字格服务器中的展示区分安全提供程序,如下示例中,“微信公众平台”即为Name属性:
此外,一个比较重要的概念是,“Name”属性是安全提供程序的唯一标识,如果您想上传了两个Name属性的值相同的安全提供程序,则上传第二个时,会有覆盖提醒:
UserInformations 是一个特定的数据结构,其用于定义第三方系统中所有的用户信息。
当您希望将第三方系统中的用户集成到活字格管理控制台时,就需要实现此属性。
每当安全提供程序被加载以及数据同步时,活字格管理控制台就会通过此属性读取第三方系统中的用户信息并与活字格服务器进行集成。
如下图所示就是一个传递了 UserInfomations 属性的安全提供程序,当此属性值不为空时,属性中的用户信息会加载到 活字格管理控制平台 中:
说明:
此属性只有当 UserInformationStorageMode == InMemoryCache 才会被读取。具体可见 UserInformationStorageMode 部分内容。
AuthenticationType 是一个枚举值,它代表了第三方用户的认证模式。对于不同的认证模式,活字格管理服务器会有不同的处理策略。
这是此枚举的默认值,当您给您的安全提供程序赋值 Unknown 时,活字格服务器将不认为你上传了合法的安全提供程序,并在最终登录时,会抛出安全提供程序不存在的异常信息。
这是最常见的一种认证模式,当您选择 UserNameAndPassword 时,将表示您的第三方用户信息是通过用户名以及密码进行验证的。
需要登录时,用户会跳转到活字格的内建登录页面进行登录,活字格服务器会将用户录入的用户名以及密码通过入参传递到安全提供程序中,并由安全提供程序最终验证用户登录信息并最终返回用户详情信息。
使用这种认证方式,您需要自行在安全提供程序中完成用户名以及密码的校验逻辑,并将正确的用户信息进行返回。
第一章的示例代码就是以这种方式进行登录验证的。
如果使用 WindowsAuthentication 表示您将使用 Windows 域作为用户认证模式。
当您选择了这种认证模式后,活字格站点将会把自己的用户认证 Schemes 改为 AuthenticationSchemes.NTLM
,这表示 Windows 域将自动验证登录用户的信息。
此时,用户认证这样工作就不由安全提供程序提供了,而由 Windows 域提供。此时,您需要做的就是为活字格服务器提供 Windows 域下的用户信息,即提供 UserInformations 属性。
需要注意的是:
此认证模式只能在 Windows 中使用,其他系统无法正常工作。
OpenId 即 OpenID Connect(OIDC),是建立在 OAuth2 之上的身份认证协议,它扩展了 OAuth2,以提供对用户身份的认证和用户信息的获取。
几乎对接所有第三方系统都会使用到的认证方式,例如微信,新浪,QQ
等。
每当用户需要登录活字格服务器时,活字格会通过安全提供程序中的配置信息,获取到第三方站点的登录重定向地址,并将用户登录页面重定向到第三方站点供用户登录。
当用户在第三方站点登录成功后,基于OAuth2 协议,第三方站点会通过 Callback 地址请求活字格服务器中的登录地址,并返回 OpenID。
活字格服务器继续通过 OpenID 向安全提供程序获取用户详细信息,并最终记录活字格站点的用户认证策略,最终用户登录成功。
需要注意的是:
在这种模式下,安全提供程序是活字格服务器连接第三方系统的媒介,基于 OAuth2 协议,安全提供程序也需要特定的认证信息(通常为 ClientID + SecretKey的模式),为了灵活,这些信息应该做成可配置的。
因此,在这种模式下,您应该要额外实现一个接口 ISupportSettings 来完成第三方系统的登录配置。
此外,从上述流程可以看到,第三方系统的登录重定向地址也需要有安全提供程序提供,由于历史原因,您需要再额外实现一个接口 IOpenIdSecurityProvider 来提供登录重定向地址。
Saml 是 Security Assertion Markup Language,是一个单点登录协议,一般国外的网站(Google,Salesforce等)都是基于此协议进行用户认证。
Saml 的登录过程比较复杂,其主要原理是 IdP 为认证信息提供服务,记录着用户的所有登录信息,每当 SP 即服务提供方想要登录时,需要向 IdP 获取用户登录所需的标记文本(XML格式)从而登录。
但是,活字格服务器并不能直接作为 SP,因此,Saml认证模式下的安全系统程序,需要起到链接 IdP 与 SP 的功能。
此协议国内不是很常见,您可以仅做了解。
此属性是为了指定第三方的用户信息在活字格服务器中的保存方式。
当您指定 UserInformationStorageMode 为 InForguncyDatabase 时,用户首次登陆时,会把用户添加到活字格的用户数据库中。
管理员需要到活字格的用户数据库中为用户分配角色和组织信息。如果用户已经被加入到活字格的用户数据库中,此时在第三方应用中删除该用户或者修改用户属性不会自动同步到活字格的数据库中。
当您指定 UserInformationStorageMode 为 InMemoryCache 时,活字格会在网站启动时从第三方应用获取全部用户信息,包括用户,用户属性,角色及组织结构信息。
并且缓存在网站的内存中,活字格应用会使用这些用户信息判断权限,工作流程等。活字格会每间隔一定时间从第三方网站同步一次最新的用户数据(默认为20分钟),可以根据业务需要调整同步的间隔时间。
一般情况下,如果认证模式是 UserNameAndPassword 时,该属性的值应该为True。
如果认证模式为 WindowsAuthentication 时,该属性值为False。
如果认证模式是 OpenId 时,该属性值有可能为 True 有可能为 False。
VerifyUser() 作为接口中唯一的方法,其主要功能就是认证用户登录信息并返回用户详细信息。
通常情况下(非 WindowsAuthentication 认证模式下),每当用户需要登录时,活字格服务器就会请求此方法。
而登录的具体信息,则会通过字典类型入参 properties
传入,其定义具体如下:
如果AuthenticationType是 UserNameAndPassword,包含的属性有 “userName” 和 “password”;
如果AuthenticationType是 WindowsAuthentication, 这个方法不需要实现;
如果AuthenticationType是 OpenId,不同的网站需要认证的属性不同,通常是token,code,state等。
具体的 OpenId 模式下的入参通过此方法获取:
private static async Task<Dictionary<string, string>> GetRequestDataAsync(HttpContext context)
{
var data = new Dictionary<string, string>();
if (context.Request.Method == "POST")
{
var form = await context.Request.ReadFormAsync();
foreach (var item in form)
{
data[item.Key] = item.Value.FirstOrDefault();
}
}
else if (context.Request.Method == "GET")
{
foreach (var item in context.Request.Query)
{
data[item.Key] = item.Value.FirstOrDefault();
}
}
return data;
}
您可以从定义发现,此方法除了认证用户信息外,还需要返回用户的详细信息,以提供给活字格服务器作为登录凭证记录在 Cookie 中。
上一节中,我们为您介绍了安全提供程序的核心接口 ISecurityProvider。但是,如果您只实现此接口,几乎只能做 UserNameAndPassword 模式的认证。
在您与第三方系统对接时,或多或少都需要建立一些授权信息,而这些信息则需要使用者进行参数配置。
这些配置信息当然可以在安全提供程序中写死,但是这样就导致安全提供程序和第三方账号绑定,想要切换账号必须修改安全提供程序的源代码,这明显不是一个很好的设计。
因此,我们为您提供了 ISupportSettings 接口用来在安全提供程序外定义一些配置信息。只要实现了此接口,那么活字格管理控制平台中就会展示安全系统程序的配置参数。
目前 ISupportSettings 接口只需要实现一个属性Settings用来展示参数列表,一个方法**UpdateSetting()**用以更新参数配置。
Settings是一个SecurityProviderSettings类型的集合,您可以在集合内定义任何想要的参数。
目前我们为您提供了 7 种不同类型的参数。
文本框可以录入普通文本类型参数,比如ClientId等无需加密的内容。
文本框没有其他额外的配置,直接使用即可。
new SecurityProviderSettings()
{
Name = "TextBox",
DisplayName = "TextBox 参数",
Description = "这里可以写一些关于 TextBox 参数的描述",
Editor = new TextBoxEditor(),
Value = "hello world",
},
多行文本框和普通文本框的唯一区别就是可以录入可换行的文本内容。
多行文本框也没有其他额外的配置,直接使用即可。
new SecurityProviderSettings()
{
Name = "TextArea",
DisplayName = "TextArea 参数",
Description = "TextArea是多行分文参数",
Editor = new TextAreaEditor(),
Value = @"hello
world",
},
密码框中的参数会通过SHA256非对称加密传输,其中加密的逻辑您无需关心,最终获取到的 Value 即为解密后的内容。
密码框也没有其他额外的配置,直接使用即可。
new SecurityProviderSettings()
{
Name = "Password",
DisplayName = "Password 参数",
Description = "Password参数默认是加密后传递的",
Editor = new PasswordEditor(),
Value = "123456",
},
标签作为仅展示的值,无法在活字格管理控制平台修改。
标签也没有其他额外的配置,直接使用即可。
new SecurityProviderSettings()
{
Name = "Label",
DisplayName = "Label 参数",
Description = "Label参数不可编辑,基本是用于展示的",
Editor = new LabelEditor(),
Value = "LabelValue",
},
普通的组合框展示的内容即为最终的 Value。
其需要额外配置Items属性才可以正确展示内容。
new SecurityProviderSettings()
{
Name = "Combo",
DisplayName = "Combo 参数",
Description = "普通的 Combo 类型参数显示内容即为获取到的值",
Editor = new ComboEditor()
{
Items = new List<string>()
{
"参数1",
"参数2",
"参数3",
}
},
Value = "参数2",
},
高级组合框和普通组合框的区别是,它可以将显示值和最终值做区分。
同样的,您也需要配置Items属性才可以使用,与普通组合框不同的是,高级组合框的Items属性是AdvancedSourceItem类型,其中包括两个参数:DisplayValue以及Value。
顾名思义,DisplayValue为展示在前端的内容,Value为最终获取到的值。
new SecurityProviderSettings()
{
Name = "AdvancedCombo",
DisplayName = "AdvancedCombo 参数",
Description = "AdvancedCombo 与普通 Combo 的区别在于,AdvancedCombo 可以设置显示值与实际值不同",
Editor = new AdvancedComboEditor()
{
Items = new List<AdvancedSourceItem>()
{
new AdvancedSourceItem() { DisplayValue = "显示参数1", Value = "1" },
new AdvancedSourceItem() { DisplayValue = "显示参数2", Value = "2" },
new AdvancedSourceItem() { DisplayValue = "显示参数3", Value = "3" },
},
},
Value = "3"
},
文件上传控件可以为安全提供程序上传所需的配置文件数据,最终获取到的 Value 为上传后的文件地址,可以通过 File 相关接口读取文件内容。
您可以为文件上传控件配置可接收的文件类型Accept,其默认如果不配置则支持所有文件。
new SecurityProviderSettings()
{
Name = "Upload",
DisplayName = "Upload 参数",
Description = "Upload 可以上传文件,其 Value 即为上传后文件的路径",
Editor = new UploadEditor()
{
Accept = "*.*"
},
},
有时,参数的展示可能是跟随其他参数而联动的,比如:当用户将 Combo 参数选择为 A 时,后续的参数展示 A 相关的内容,选择 B 则展示 B 相关的内容。
如果您有以上的需求场景,那么可以使用所有Editor属性上的 PropertyBindings 属性来做到。
您可以设置不同参数 Editor 上的 PropertyBindings 属性来控制属性的展示状态。如下述代码所示:
public List<SecurityProviderSettings> Settings { get; } = new()
{
new SecurityProviderSettings()
{
Name = "Combo",
DisplayName = "想要切换的参数",
Description = "切换此参数会使后续的参数联动展示或隐藏",
Editor = new ComboEditor()
{
Items = new List<string>()
{
"A 类型",
"B 类型",
"C 类型",
}
},
Value = "A 类型",
},
new SecurityProviderSettings()
{
Name = "TextBoxA",
DisplayName = "A 类型文本",
Description = "A 类型下的文本参数",
Editor = new TextBoxEditor()
{
PropertyBindings = new List<EditorPropertyBinding>()
{
new EditorPropertyBinding()
{
PropertyType = EditorPropertyType.Visibility,
BindingTarget = "Combo", // 想要切换的参数控件的Name值
BindingValue = "A 类型" // 期望当 Combo 的Value 为 A 类型 时展示出来
}
},
},
Value = "AAA",
},
new SecurityProviderSettings()
{
Name = "Password",
DisplayName = "A 类型密码",
Description = "A 类型下的密码参数",
Editor = new PasswordEditor()
{
PropertyBindings = new List<EditorPropertyBinding>()
{
new EditorPropertyBinding()
{
PropertyType = EditorPropertyType.Visibility,
BindingTarget = "Combo", // 想要切换的参数控件的Name值
BindingValue = "A 类型", // 期望当 Combo 的Value 为 A 类型 时展示出来
}
},
},
Value = "123456",
},
new SecurityProviderSettings()
{
Name = "TextBoxB",
DisplayName = "B 类型文本",
Description = "B 类型下的文本参数",
Editor = new TextBoxEditor()
{
PropertyBindings = new List<EditorPropertyBinding>()
{
new EditorPropertyBinding()
{
PropertyType = EditorPropertyType.Visibility,
BindingTarget = "Combo", // 想要切换的参数控件的Name值
BindingValue = "B 类型" // 期望当 Combo 的Value 为 B 类型 时展示出来
}
},
},
Value = "BBB",
},
};
上述代码中,安全提供程序共有4个参数,而其中的3个参数受到 Combo 参数的影响:
当 Combo 参数选择 "A 类型"时,【A 类型文本】与【A 类型密码】会展示。
当 Combo 参数选择 "B 类型"时,【A 类型文本】与【A 类型密码】会隐藏,而【B 类型文本】参数会展示。
当 Combo 参数选择 "C 类型"时,【A 类型文本】与【A 类型密码】以及【B 类型文本】参数都会隐藏。
此外,如果联动展示的参数中,设置了 Invert == True,则表示满足 Value 时不会展示,不满足 Value 时才会展示。
以上述示例中的A 类型文本为例:
new SecurityProviderSettings()
{
Name = "TextBoxA",
DisplayName = "A 类型文本",
Description = "A 类型下的文本参数",
Editor = new TextBoxEditor()
{
PropertyBindings = new List<EditorPropertyBinding>()
{
new EditorPropertyBinding()
{
PropertyType = EditorPropertyType.Visibility,
BindingTarget = "Combo", // 想要切换的参数控件的Name值
BindingValue = "A 类型",
Invert = true // 如果这里设置为 true 则表示 Combo 属性的值为 A 类型时,则当前属性不会展示
}
},
},
Value = "AAA",
},
参数 Editor 上除了 PropertyBindings 以外还有一个 IsHidden 属性。
默认情况下此属性都为 False,如果您将其设置为 True,则在前端不会展示此属性,但是依然可以获取到 Value。
上一小节中,我们介绍了安全提供程序参数的数据结构以及控件展示。但是展示只是第一步,我们最终的目的是要在活字格管理控制平台中随时修改这些参数配置。
每当在管理控制平台中单击“保存设置”按钮,都会调用 UpdateSetting() 方法进行参数保存。
每当保存时,我们会将在前端设置好的数据通过字典类型的参数传入**UpdateSetting()**方法中,您可以在方法中进行简单的内存保存(以上述参数作为示例):
private readonly ParamDto _config = new ParamDto();
public void UpdateSetting(Dictionary<string, object> dictionary)
{
if (dictionary.TryGetValue(_config.Combo, out var comboValue))
{
_config.Combo = comboValue;
}
if (dictionary.TryGetValue(_config.TextBoxA, out var textBoxAValue))
{
_config.TextBoxA = textBoxAValue;
}
if (dictionary.TryGetValue(_config.TextBoxB, out var textBoxBValue))
{
_config.TextBoxB = textBoxBValue;
}
if (dictionary.TryGetValue(_config.Password, out var passwordValue))
{
_config.Password = passwordValue;
}
}
但是存储在内存中有一个问题,只要活字格管理控制平台重启了,所有的配置信息就丢失了,需要重新配置。
这很显然不是一个很好的体验。为此,我们建议您将参数配置通过文件的形式持久化。
1. 首先在安全提供程序集中,加入一个config.json的文件作为持久化文件。
2. 打开安全提供程序的程序集文件,加入持久化文件的拷贝逻辑:
<ItemGroup>
<None Include="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
3. 通过反射进行文件读写
public void UpdateSetting(Dictionary<string, object> dictionary)
{
var configDto = LoadConfig();
foreach (var property in configDto.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!dictionary.TryGetValue(property.Name, out var value))
{
continue;
}
property.SetValue(configDto, value);
}
SaveConfig(configDto);
}
private static ParamDto LoadConfig()
{
var assemblyLocation = Assembly.GetExecutingAssembly().Location;
if (AppDomain.CurrentDomain.ShadowCopyFiles)
{
Uri uri = new Uri(Assembly.GetExecutingAssembly().Location);
assemblyLocation = uri.LocalPath;
}
var dir = Path.GetDirectoryName(assemblyLocation);
var configFile = Path.Combine(dir, "config.json");
return JsonConvert.DeserializeObject<ParamDto>(File.ReadAllText(configFile))!;
}
private void SaveConfig(ParamDto config)
{
var assemblyLocation = Assembly.GetExecutingAssembly().Location;
if (AppDomain.CurrentDomain.ShadowCopyFiles)
{
Uri uri = new Uri(Assembly.GetExecutingAssembly().Location);
assemblyLocation = uri.LocalPath;
}
var dir = Path.GetDirectoryName(assemblyLocation)!;
var configFile = Path.Combine(dir, "config.json");
MakeFileNotReadOnly(configFile);
File.WriteAllText(configFile, JsonConvert.SerializeObject(config));
}
private void MakeFileNotReadOnly(string fileName)
{
if (File.Exists(fileName))
{
if ((File.GetAttributes(fileName) & FileAttributes.ReadOnly) == FileAttributes.ReadOnly)
{
File.SetAttributes(fileName, File.GetAttributes(fileName) & ~FileAttributes.ReadOnly);
}
}
}
至此,您的参数配置即可以持久化到文件中了。以后重新管理控制平台,依然可以保存上一次的配置信息。
由于历史版本兼容问题,在您使用不同的认证模式时,可能还需要额外实现一些接口进行配合。本节将为您介绍这些特定认证模式下所需要的接口。
想要使用 OpenId 作为第三方系统的集成模式,您还需要额外实现 IOpenIdSecurityProvider 接口。
此接口只需要实现一个方法 GetRedirectUrl(),用以在登录时获取第三方站点的登录地址。
方法共有三个入参:
redirect_uri:登录成功后的重定向地址,当您的用户在第三方系统登录成功后,依照 OAuth2.0 协议,需要跳转回一个活字格服务器的地址,即为此地址
state:是一个用于校验的参数,通常需要此参数防止跨站攻击
userAgent:用户使用的 Agent 信息,通常用以判断是否是手机端
如下是一个 钉钉 的OpenId登录地址获取逻辑示例:
public string GetRedirectUrl(string redirect_uri, string state, string userAgent)
{
if (!NeedRedirect(userAgent))
{
return null;
}
NameValueCollection queryString = System.Web.HttpUtility.ParseQueryString(string.Empty);
var config = LoadConfig();
queryString["appid"] = config.AppId;
queryString["response_type"] = "code";
queryString["scope"] = "snsapi_auth";
queryString["state"] = state;
queryString["redirect_uri"] = redirect_uri;
var str = queryString.ToString(); // Returns "key1=value1&key2=value2", all URL-encoded
var url = "https://oapi.dingtalk.com/connect/oauth2/sns_authorize?" + str;
return url;
}
如果您想要使用 Saml 认证,则需要额外实现 ISamlSecurityProvider 接口。
此接口没有任何实现,活字格服务器内部会通过此接口校验 Saml 所需的认证策略。
以上小节所介绍的接口都是安全提供程序所必须的接口,除了上述那些,我们还提供几个额外的可选接口,用以提升代码性能以及可用性。
从上面的介绍您可以发现,默认在 ISecurityProvider 中的校验方法是同步的。
对于高并发场景,同步校验的逻辑会有明显的性能问题。
由于历史兼容性问题,我们无法修改以前的接口。为此,我们额外提供了一个 IVerifyUserAsync 用以提供异步校验逻辑。
其接口只有一个方法提供异步用户校验的逻辑:
Task<User> VerifyUserAsync(Dictionary<string, string> properties);
当您的安全提供程序中使用到了此接口,所有的用户校验策略我们会优先异步方法。
此接口用于将第三方系统中的用户与活字格服务器中的用户相关联。
说明:
此接口只在 UserInformationStorageMode == InForguncyDatabase 才会生效
此接口只能基于 OpenId 的认证模式才会生效
在不使用此接口的情况下,OpenId 在第三方系统登录成功后,会向活字格的用户数据库中写入一条以 OpenId 作为用户名的用户数据,未来登录活字格的系统就使用此用户实现。
当您使用了 IAllowRelativeToExistForguncyUser 接口后,上述逻辑就会发生改变:
当您使用了此接口后,活字格管理控制平台中,安全提供程序的参数中会多出一个用户创建方式供您选择:
如果您选择了“以OpenID作为用户名”选项(默认为此选项),则所有的逻辑和不使用 IAllowRelativeToExistForguncyUser 接口完全一致。
如果您选择了“由用户决定用户名和密码”选项,则当您在第三方系统登录成功后,会跳转到一个用户关联页(PC与手机共用此页面)来关联您的一个已有账号:
当您关联了一个已有账号后,活字格将不会写入以OpenId为用户名的活字格用户,而是以您关联的账号作为活字格系统内部的账号使用。
当然,如果您还没有对应的账号还可以跳转创建一个可关联的账号:
这时,活字格管理控制平台的用户列表页面会自动多一个自定义属性 OpenID(此自定义属性的名称可以在安全提供程序参数配置中修改):
您关联成功的用户的 OpenID 就会存储在此字段中。
未来,当您直接通过PC登录活字格站点时,就可以使用您关联的用户进行登录操作了。
下一节
在本章节中,我们向您介绍了安全提供程序所需要的所有核心接口以及用法。至此,您应该可以开发一个集成第三方系统的安全提供程序了。
但是开发完毕后,还需要打包测试,因此,下一节,我们将为您介绍如何打包测试安全提供程序。