在 SharePoint 中使用智能卡签名文档
本教程介绍如何使用智能卡 API、SharePoint 扩展和 Web 服务,为 SharePoint Online 库中的 PDF 文件添加合格电子签名 (QES)。
引言
合格电子签名 (QES) 的一种实现方式是通过智能卡,智能卡在芯片中包含适用于文档签名的电子证书。这类卡是身份证的扩展,芯片上的证书仅适用于身份识别,因为它们缺乏作为合格签名创建设备的功能。
尽管一些行业可能会在其智能卡中添加签名功能,但智能卡作为标准文档签名形式的普及使用可能仅限于葡萄牙等国家/地区,这些国家/地区的公民身份证中已包含此功能。因此,大多数商业文档签名解决方案甚至不会提及智能卡。
特别是对于 SharePoint,在应用程序商店中找到的任何用于管理文档的应用程序都没有以任何方式提及智能卡。
鉴于其作为一种不太常见的文档签名形式的性质,在网上找到的关于如何集成 SharePoint 文档库和智能卡以避免手动下载文档、使用智能卡的特定应用程序对其进行签名,然后再将其作为新版本重新上传的全面方法的信息很少。
尽管提供了功能齐全的演示,但本文的目的是识别所需的各个连接部分以及它们如何协同工作,以便浏览器从服务器拉取文档、将其发送以由卡的中间件签名、接收签名的文件并将其存储在服务器上,而不是展示任何新颖的用途或每个步骤的最佳实现。
为了让那些不熟悉 SharePoint 前端 Web 开发或桌面应用程序开发的人能够理解本文,前面包含了一个关于设置所需工具的章节。
环境设置
获取与您的卡兼容的 SDK
由于整个过程中最艰巨的部分是编程签名您的文档,因为它涉及 USB 读卡器、证书、安全 PIN、时间戳服务器等。项目的整体难度大致与获取卡的 SDK 的难度相当。
如果包含用于手动签名的软件的网页没有提及 SDK 或 API,请不要灰心,网络搜索可能会像对我一样找到它[Manual do SDK – Middleware do Cartão de Cidadão]。
如果您找不到开箱即用的允许您以编程方式与您的卡交互的工具,另一个选项可能是欧洲标准 DSS 库。
设置 SharePoint 开发环境
此解决方案是使用 SharePoint 框架 (SPFx) 构建的,但同样的功能可能通过扩展功能区和菜单的 SharePoint 插件 UI 命令来实现。
选择 SPFx 时,必须设置开发环境。这并不难,但我推荐 YouTube 视频,因为它们包含旁白者未在文本版本中包含的评论,并显示安装过程中出现很多警告是正常的。
如果没有权限将应用程序部署到您的 SharePoint 租户,设置 Microsoft 365 租户进行开发将允许您执行所有测试和部署[SharePoint Framework 教程 - 为开发设置您的 Microsoft 365 租户]。
然后是实际的设置您的 SharePoint 框架 (SPFx) 开发环境[设置您的 SharePoint 框架 (SPFx) 开发环境]。
设置本地开发环境
解决方案的第二部分是为 C# SDK 创建一个 Windows 应用程序形式的包装器,因此使用了Visual Studio 并选择了 .NET 桌面开发工作负载[安装 Visual Studio]。对于不同语言、不同操作系统或根据偏好选择的 SDK,不同的代码编辑器可能提供更好的体验。
SPFx 扩展
生成基本的 SPFx 扩展脚手架
为了向 SharePoint Online 文档库添加新的可配置按钮,“构建您的第一个 ListView 命令集扩展”[构建您的第一个 ListView 命令集扩展] 提供了一个坚实的基础,但有以下注意事项
在./src/extensions/helloWorld/HelloWorldCommandSet.manifest.json中,如果 yeoman 无法创建引用的图像,为了防止构建错误,一个快速的解决方案是简单地注释掉它们。
"items": {
[…]
//"iconImageUrl": "icons/request.png",
[…]
//"iconImageUrl": "icons/cancel.png",
在./sharepoint/assets/element.xml中,我们可以定义一旦部署到服务器,新按钮将出现在文档库而不是通用列表中,方法是指定适当的RegistrationId
[构建您的第一个 ListView 命令集扩展]。
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<CustomAction
[…]
RegistrationId="101"
./config/serve.json中定义的默认和可选页面仅用于本地测试,一旦部署到服务器,新按钮将出现在所有文档库中。
添加用于文件 IO 的 Web 服务
利用本地托管的 Web 服务,可以在不依赖浏览器插件的情况下,在浏览器上下文之外移动数据。
虽然PnP/PnPjs 库提供了许多优势,但为了简单起见,本解决方案仅使用基本的 SPFx 功能[SharePoint Framework 参考] 和 SharePoint REST 服务[了解 SharePoint REST 服务]。
在./src/extensions/helloWorld/HelloWorldCommandSet.ts中
添加用于与 SharePoint Web 服务 (SPHttp*) 交互的引用可以公开文档库,而与通用 (Http*) 交互则公开包装的读卡器。
import {
SPHttpClient,
SPHttpClientResponse,
ISPHttpClientOptions,
HttpClient,
HttpClientResponse,
IHttpClientOptions
} from '@microsoft/sp-http';
扩展接口允许方便地存储属性,包括被操作的文件作为 blob
。
export interface IAssinarDocumentoCommandSetProperties {
// This is an example; replace with your own properties
sampleTextOne: string;
sampleTextTwo: string;
selectedfilename: string;
selectefiledId: string;
siteurl: string;
siterelativeurl: string;
libraryguid: string;
libraryrelativurl: string;
librarytitle: string;
fileblob: Blob;
}
某些属性特定于 Web 页面,可以在 onInit
期间设置,因为每次用户导航到不同库时都会重置这些属性。
public onInit(): Promise<void> {
Log.info(LOG_SOURCE, 'Initialized AssinarDocumentoCommandSet');
// initial state of the command's visibility
const compareOneCommand: Command = this.tryGetCommand('COMMAND_1');
compareOneCommand.visible = false;
this.context.listView.listViewStateChangedEvent.add
(this, this._onListViewStateChanged);
this.properties.siteurl = this.context.pageContext.web.absoluteUrl;
this.properties.siterelativeurl = this.context.pageContext.web.serverRelativeUrl;
this.properties.librarytitle = this.context.listView.list.title;
this.properties.libraryrelativurl = this.context.listView.list.serverRelativeUrl;
this.properties.libraryguid = this.context.listView.list.guid.toString();
return Promise.resolve();
}
其他属性特定于库列表中选择的项目,必须为每个项目选择的更改重置。
private _onListViewStateChanged = (args: ListViewStateChangedEventArgs): void => {
[…]
// TODO: Add your logic here
// You can call this.raiseOnChage() to update the command bar
this.raiseOnChange();
this.properties.selectedfilename =
this.context.listView.selectedRows[0].getValueByName('FileLeafRef');
this.properties.selectefiledId =
this.context.listView.selectedRows[0].getValueByName('UniqueId');
}
三个必不可少的功能是
- 将库中的文档获取为
blob
private _getFileData(libraryrelativurl: string, filename: string): Promise<Blob> { return this.context.spHttpClient.get(this.properties.siteurl + "/_api/web/GetFileByServerRelativeUrl('" + libraryrelativurl + "/" + filename + "')/$value", SPHttpClient.configurations.v1) .then((response: SPHttpClientResponse) => { return response.blob(); }); }
- 将文档发布到读卡器 SDK 包装器作为 blob 并接收签名。虽然命名管道是首选方法,但使用本地主机 http Web 服务是一种不安全但便捷的替代方案。
private _postFileData(blob: Blob, filename: string): Promise<Blob> { const httpClientOptions: IHttpClientOptions = { body: blob, }; return this.context.httpClient.post ("https://:81/SignFile?filename=" + filename, HttpClient.configurations.v1, httpClientOptions) .then((response: HttpClientResponse) => { return response.blob(); }); }
- 将其作为新版本存储回 SharePoint Online 库,文件名相同
private _storeFileData(blob: Blob, librarytitle: string, filename: string): Promise<string> { const sphttpClientOptions: ISPHttpClientOptions = { body: blob, }; return this.context.spHttpClient.post(this.properties.siteurl + "/_api/Web/Lists/getByTitle('" + librarytitle + "')/RootFolder/Files/Add (url='" + filename + "', overwrite=true)", SPHttpClient.configurations.v1, sphttpClientOptions) .then((response: SPHttpClientResponse) => { return response.statusText; }); }
有一个函数来测试与读卡器的通信很方便,但并非必不可少。
private _testLocalHost(): Promise<string> {
return this.context.httpClient.get("https://:81/TestAssinador",
HttpClient.configurations.v1)
.then((response: HttpClientResponse) => {
return response.text();
});
}
这些功能随后可以在每次按下按钮时调用,并通过 .then
链接。
稍后部署时,将需要添加 .catch
来处理每次异步调用中的异常。
public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
switch (event.itemId) {
case 'COMMAND_1':
const filename: string = this.properties.selectedfilename;
this._getFileData(this.properties.libraryrelativurl, filename)
.then((response) => {
this._postFileData(response, filename)
.then((response) => {
if (response.size === 0) {
Dialog.alert("Erro na tantativa de assinatura").catch(() => { });
}
else {
this._storeFileData
(response, this.properties.librarytitle, filename)
.then((response) => {
this.properties.sampleTextOne = response;
Dialog.alert("Pdf assinado com sucesso").catch(() => { });
})
.catch((reason) => {
Dialog.alert(reason.toString()).catch(() => { });
})
}
})
.catch((reason) => {
Dialog.alert("Erro na tantativa de assinatura").catch(() => { });
})
})
.catch(() => {});
break;
case 'COMMAND_2':
this._testLocalHost()
.then((response) => {
Dialog.alert(response).catch(() => { });
})
.catch(() => { });
break;
default:
throw new Error('Unknown command');
}
}
此时,如果省略 _postFileData
部分,代码可以进行测试,以显示一个相同文件的新版本被添加到文档库中。
因为在获取答案的承诺方面没有竞争,如果调用本地主机发送文件或调用测试地址,将什么也不会发生,因为浏览器将简单地无限期等待。
之前引用的有关如何构建基本 SharePoint 扩展的相同文档也包含有关如何测试和部署的基本信息[构建您的第一个 ListView 命令集扩展]。
在./config/package-solution.json中,可能需要删除 elementManifests
下的 clientsideinstance.xml 条目,以允许将扩展限制在特定站点而不是租户中的所有站点。[将您的扩展部署到 SharePoint (Hello World 第 3 部分)]。
卡 API 包装器
使用了葡萄牙公民卡 API [Manual do SDK – Middleware do Cartão de Cidadão]。因为它本身不提供对内存中 PDF 文件进行签名的功能,所以在包装器中添加了一些额外的 HDD IO 功能,而不是 fork API。
尽管包装器没有添加任何用户界面,但由于 API 本身会弹出窗口要求用户输入 PIN,因此包装器将是一个隐秘地驻留在任务托盘中的 Windows 应用程序,而不是一个服务[Session 0 隔离]。
通过运行应用程序上下文而不是窗体,可以使 .NET Windows Forms 应用程序成为无窗口应用程序[创建任务托盘应用程序]。由于唯一的用户界面是图标菜单上的退出选项,因此使用更现代的 UI 框架的理由很少。
托管的 Web 服务是用 WCF 构建的[如何:在托管应用程序中托管 WCF 服务]。如果选择像gRPC 这样更现代的解决方案,请注意,因为 SharePoint 页面中的 JavaScript 在将文档发送以签名时(https://:81)需要调用不同的源,因此该端点的 Web 服务将必须支持CORS。
由于要签名的文件大小超过了默认值,因此定义消息大小是必要的
webHttpBinding.MaxReceivedMessageSize = 100000000;
WCF 中 CORS 的实现有详细文档[CORS on WCF],提供的代码只需稍作修改即可使用。为了弥补缺少 web.config 文件,端点行为在 C# 中添加。
ServiceEndpoint ep =
serviceHost.AddServiceEndpoint(typeof(IAssinador), webHttpBinding, "");
ep.Behaviors.Add(new EnableCrossOriginResourceSharingBehavior());
由此产生的 ApplicationContext
变为
public class AssinadorApplicationContext : ApplicationContext
{
public WebServiceHost serviceHost = null;
private NotifyIcon trayIcon;
public AssinadorApplicationContext()
{
// Initialize Tray Icon
trayIcon = new NotifyIcon()
{
Icon = Resources.AppIcon,
ContextMenu = new ContextMenu(new MenuItem[] {
new MenuItem("Exit", Exit)
}),
Visible = true
};
//Initialize Service
StartService();
}
protected void StartService()
{
if (serviceHost != null)
{
serviceHost.Close();
}
// Create a ServiceHost and provide the base address.
serviceHost = new WebServiceHost(typeof(AssinadorService),
new Uri("https://:81"));
try
{
WebHttpBinding webHttpBinding = new WebHttpBinding();
webHttpBinding.MaxReceivedMessageSize = 100000000;
ServiceEndpoint ep = serviceHost.AddServiceEndpoint(typeof(IAssinador),
webHttpBinding, "");
ep.Behaviors.Add(new EnableCrossOriginResourceSharingBehavior());
// Open the ServiceHostBase to create listeners and start
// listening for messages.
serviceHost.Open();
}
catch (Exception)
{
serviceHost.Abort();
throw;
}
}
protected void Exit(object sender, EventArgs e)
{
if (serviceHost != null)
{
serviceHost.Close();
serviceHost = null;
}
// Hide tray icon, otherwise it will remain shown until user mouses over it
trayIcon.Visible = false;
Application.Exit();
}
}
同样由于缺少 web.config 文件,EnableCrossOriginResourceSharingBehavior
可以被精简[CORS on WCF]。
public class EnableCrossOriginResourceSharingBehavior : IEndpointBehavior
{
public void AddBindingParameters(ServiceEndpoint endpoint,
System.ServiceModel.Channels.BindingParameterCollection bindingParameters) { }
public void ApplyClientBehavior(ServiceEndpoint endpoint,
System.ServiceModel.Dispatcher.ClientRuntime clientRuntime) { }
public void ApplyDispatchBehavior(ServiceEndpoint endpoint,
System.ServiceModel.Dispatcher.EndpointDispatcher endpointDispatcher)
{
var requiredHeaders = new Dictionary<string, string>();
requiredHeaders.Add("Access-Control-Allow-Origin", "*");
requiredHeaders.Add("Access-Control-Request-Method",
"POST,GET,PUT,DELETE,OPTIONS");
requiredHeaders.Add("Access-Control-Allow-Headers",
"X-Requested-With,Content-Type");
endpointDispatcher.DispatchRuntime.MessageInspectors.Add
(new CustomHeaderMessageInspector(requiredHeaders));
}
public void Validate(ServiceEndpoint endpoint) { }
}
对于服务合同,除了测试和签名服务之外,还定义了第三个操作来处理选项请求并防止服务返回错误。
[ServiceContract]
public interface IAssinador
{
[OperationContract]
[WebGet(ResponseFormat = WebMessageFormat.Json)]
string TestAssinador();
[OperationContract]
[WebInvoke(ResponseFormat = WebMessageFormat.Json,
UriTemplate = "SignFile?filename={filename}")]
Stream SignFile(Stream data, string filename);
[OperationContract]
[WebInvoke(Method = "OPTIONS", UriTemplate = "*")]
void PostOptions();
}
服务的行为只是调用卡片 SDK 以及前面提到的额外功能。
用于 HDD IO
private void TempStoreFile(Stream data, string filepath)
{
using (var fileStream = File.Create(filepath))
{
data.CopyTo(fileStream);
}
}
private Stream TempPullFile(string filepath)
{
MemoryStream memstream = new MemoryStream(File.ReadAllBytes(filepath));
File.Delete(filepath);
return memstream;
}
用于测试服务、读卡器和卡的状态
public string TestAssinador()
{
return Test();
}
private string Test()
{
StringBuilder result = new StringBuilder();
try
{
PTEID_ReaderSet.initSDK();
PTEID_ReaderSet readerSet = PTEID_ReaderSet.instance();
result.Append("Iniciação OK; ");
result.Append(readerSet.readerCount());
result.Append(" leitor(es) encontrado(s); ");
for (uint i = 0; i < readerSet.readerCount(); i++)
{
PTEID_ReaderContext context = readerSet.getReaderByNum(i);
result.Append(" leitor ");
result.Append(i);
result.Append(" presente; ");
if (context.isCardPresent())
{
PTEID_EIDCard card = context.getEIDCard();
result.Append(" Cartão encontrado no leitor ");
result.Append(i);
result.Append("; ");
//Identificação
PTEID_EId eid = card.getID();
result.Append("Dados encontrado no cartão, Nome: ");
result.Append(eid.getGivenName());
}
else
{
result.Append("Cartão não encontrado no leitor ");
result.Append(i);
}
result.Append("; ");
}
}
catch (Exception)
{
result.Append("Falha do SDK");
}
finally
{
PTEID_ReaderSet.releaseSDK();
}
return result.ToString();
}
最后,将它们与实际签名结合起来
public class AssinadorService : IAssinador
{
public string TestAssinador()
{
[...]
}
public Stream SignFile(Stream data, string filename)
{
if (filename.Substring(filename.LastIndexOf(".")) == ".pdf")
{
string filepath = Path.Combine("C:\\TesteAssPdf", filename);
if (File.Exists(filepath))
{
File.Delete(filepath);
}
TempStoreFile(data, filepath);
if (Assinar(filepath, 1, 0, 0))
{
return TempPullFile(filepath);
}
}
return null;
}
public void PostOptions() { }
private string Test()
{
[...]
}
private bool Assinar(string filepath, int pagina, double fraX, double fraY)
{
bool assinado = false;
try
{
PTEID_ReaderSet.initSDK();
PTEID_ReaderSet readerSet = PTEID_ReaderSet.instance();
for (uint i = 0; i < readerSet.readerCount(); i++)
{
PTEID_ReaderContext context = readerSet.getReaderByNum(i);
if (context.isCardPresent())
{
PTEID_EIDCard card = context.getEIDCard();
// sign only one document
PTEID_PDFSignature signature = new PTEID_PDFSignature(filepath);
signature.setSignatureLevel
(PTEID_SignatureLevel.PTEID_LEVEL_BASIC);
String output = filepath;
// Perform the actual signature
int returnCode = card.SignPDF
(signature, pagina, fraX, fraY, "", "", output);
assinado = true;
}
}
}
catch (Exception)
{
assinado = false;
if (File.Exists(filepath))
{
File.Delete(filepath);
}
}
finally
{
PTEID_ReaderSet.releaseSDK();
}
return assinado;
}
private void TempStoreFile(Stream data, string filepath)
{
[...]
}
private Stream TempPullFile(string filepath)
{
[...]
}
}
}
此时,桌面应用程序可以编译,运行位于 ./bin/Debug 中的 .exe 文件应该会在任务托盘中显示一个图标。
关注点
- Manual do SDK – Middleware do Cartão de Cidadão
- SharePoint Framework 教程 - 为开发设置您的 Microsoft 365 租户
- 设置您的 SharePoint Framework (SPFx) 开发环境
- 安装 Visual Studio
- 构建您的第一个 ListView 命令集扩展
- SharePoint Framework 参考
- 了解 SharePoint REST 服务
- 将您的扩展部署到 SharePoint (Hello World 第 3 部分)
- Session 0 隔离
- 创建任务托盘应用程序
- 如何:在托管应用程序中托管 WCF 服务
- CORS on WCF
历史
- 2022 年 8 月 3 日:提交发布