在 IIS 環境下使用 C# 操作 LibreOffice 進行檔案轉換

Web 應用要把使用者上傳的 Excel 轉成 ODS、或把報表轉成 PDF,最省事的做法之一是裝一套 LibreOffice,從後端用 soffice.exe --headless --convert-to 去跑。本機開發測試時一切正常,一部署到 IIS 就翻車:程式沒回應、找不到輸出檔、或是直接權限不足。

最容易踩進去的坑不是資料夾權限,而是「身分模擬到底有沒有作用在那支 soffice.exe 上」。網路上很多範例會教你 LogonUser 取得權杖、ImpersonateLoggedOnUser 模擬使用者,然後 Process.Start() 啟動 soffice——這套流程在 ASP.NET 裡其實達不到目的。下面先把為什麼會這樣講清楚,再給出能真正生效的寫法。

環境與會遇到的問題

需要的東西很單純:

  1. Windows Server 加 IIS
  2. LibreOffice(本文以預設安裝路徑 C:\Program Files\LibreOffice 為例)
  3. .NET Framework 的 Web 專案

部署後常見的症狀:

  1. 用一般帳號跑轉檔,soffice 起不來或沒有輸出
  2. 應用程式集區身分權限不足
  3. 轉檔過程卡住沒回應
  4. 程式跑完了,預期路徑卻找不到輸出檔
  5. 設了一堆權限還是失敗

為什麼 Impersonate 之後 Process.Start 沒用

這是整件事的關鍵,先講原理再寫程式。

ASP.NET 的身分模擬是執行緒層級的:ImpersonateLoggedOnUser 把模擬權杖(impersonation token)掛在「目前這條執行緒」上。但 CreateProcessProcess.Start 底層呼叫的就是它)建立子行程時,用的是宿主行程的主要權杖(primary token),而不是當前執行緒的模擬權杖。

結果就是:你在模擬狀態下呼叫 Process.Start() 啟動 soffice.exe,它仍然以 IIS 工作行程(應用程式集區身分)在跑,模擬完全沒傳遞下去。Microsoft 官方文件講得很直接:

in ASP.NET, impersonation is performed at the thread level and not at the process level. Therefore, any process that you spawn from ASP.NET will run under the context of the ASP.NET worker process and not under the impersonated context.

更糟的是,如果你照那種寫法在啟動前 log 一行「切換後身分」,它會印出被模擬的帳號名稱——因為當前執行緒確實在模擬狀態,於是你會誤以為一切正常,實際上 soffice 根本不是用那個身分跑的。這個假象會讓人把問題往資料夾權限那邊一路調到崩潰。

正確做法是用 LogonUser 拿到權杖後,先用 DuplicateTokenEx 把它從模擬權杖轉成主要權杖,再用 CreateProcessAsUser 以這個主要權杖去啟動 soffice.exe。這樣子行程才會真正帶著指定使用者的身分執行。

1. IIS 應用程式集區設定

先確認集區設定,這幾項與後面的方案不衝突:

  1. 開啟 IIS 管理員
  2. 找到你的應用程式集區(例如 DefaultAppPool)
  3. 右鍵 → 進階設定
  4. 設定:
    • 載入使用者設定檔 = True
    • 識別 = ApplicationPoolIdentity

這裡要先說清楚一件事,避免混淆。底下要走的是「用 Web.config 設定的具名帳號做身分模擬」這條路線,所以應用程式集區用什麼識別其實不是重點,維持預設的 ApplicationPoolIdentity 即可。真正去跑 soffice 的是你在 LogonUser 指定的那個具名帳號,不是集區身分。

換個角度:要嘛你完全靠應用程式集區身分去跑(那就把集區識別設成有權限的帳號,根本不需要 LogonUser);要嘛你走 CreateProcessAsUser 用具名帳號(那集區身分就不關鍵)。不要兩條路線混著做。本文選後者,因為它讓「轉檔用哪個帳號」這件事獨立於 IIS 集區設定,比較好控管。

2. Windows 使用者權限指派

CreateProcessAsUser 這條路線對「被模擬的那個帳號」有額外的權限要求,這點和單純 impersonation 不同,務必設對。

開啟「本機安全性原則」(Win + R 輸入 secpol.msc),依序展開:安全性設定 → 本機原則 → 使用者權限指派。

針對你要拿來跑轉檔的具名帳號(不是 IIS APPPOOL\DefaultAppPool,而是 Web.config 裡 SystemAccount 指定的那個本機帳號),加入以下權限:

  • 取代處理程序層級權杖(Replace a process level token,SeAssignPrimaryTokenPrivilege
  • 調整處理程序的記憶體配額(Increase quotas,SeIncreaseQuotaPrivilege
  • 以批次工作登入(Log on as a batch job)—— 對應 LogonUserLOGON32_LOGON_BATCH 時所需

前兩項是 CreateProcessAsUser 能成功的硬性條件,少了會在 CreateProcessAsUser 失敗。第三項對應下面程式碼用的登入類型。

關於登入類型多講一句:dwLogonType 必須和你實際授予的權限一致。LOGON32_LOGON_BATCH(值 4)要求帳號有「以批次工作登入」權限;LOGON32_LOGON_INTERACTIVE(值 2)則要求「允許本機登入」。網路上不少範例宣稱要加「以批次工作登入」,程式碼卻傳 LOGON32_LOGON_INTERACTIVE,兩邊對不上時 LogonUser 會以錯誤碼 1385(ERROR_LOGON_TYPE_NOT_GRANTED)失敗。本文程式碼統一用 LOGON32_LOGON_BATCH,搭配上面的「以批次工作登入」,服務情境下這組最合適。

3. 資料夾權限設定

具名帳號設定資料夾權限(讀取、寫入、執行):

  1. 在資料夾按右鍵 → 內容 → 安全性 → 編輯
  2. 新增該帳號(例如 .\svc_libreoffice
  3. 勾選讀取、寫入、修改

需要設定的資料夾:

  • LibreOffice 安裝目錄(通常是 C:\Program Files\LibreOffice
  • 來源資料夾
  • 目標資料夾
  • 暫存資料夾(例如 D:\temp
  • 給這個帳號一個專屬的 LibreOffice user profile 目錄(下面會解釋為什麼需要)

4. 程式碼實作

下面是修正過的版本:LogonUser 取得權杖 → DuplicateTokenEx 轉成主要權杖 → CreateProcessAsUser 以該身分啟動 soffice.exe,並把輸出與錯誤導向記錄檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.ComponentModel;
using System.IO;
using System.Configuration;
using System.Text;

public class LibreOfficeConverter
{
// dwLogonType:用批次登入,需搭配「以批次工作登入」權限
private const int LOGON32_LOGON_BATCH = 4;
private const int LOGON32_PROVIDER_DEFAULT = 0;

// DuplicateTokenEx / CreateProcessAsUser 用的常數
private const int SecurityImpersonation = 2; // SECURITY_IMPERSONATION_LEVEL
private const int TokenPrimary = 1; // TOKEN_TYPE
private const uint MAXIMUM_ALLOWED = 0x02000000;
private const int CREATE_NO_WINDOW = 0x08000000;

[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}

[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess, hThread;
public uint dwProcessId, dwThreadId;
}

[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int Length;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}

[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool LogonUser(
string lpszUsername, string lpszDomain, string lpszPassword,
int dwLogonType, int dwLogonProvider, out IntPtr phToken);

[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool DuplicateTokenEx(
IntPtr hExistingToken, uint dwDesiredAccess, ref SECURITY_ATTRIBUTES lpTokenAttributes,
int ImpersonationLevel, int TokenType, out IntPtr phNewToken);

[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessAsUser(
IntPtr hToken, string lpApplicationName, string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes, ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles, int dwCreationFlags, IntPtr lpEnvironment,
string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);

/// <summary>
/// 以 Web.config 指定的具名帳號身分執行 LibreOffice 轉檔。
/// </summary>
/// <param name="sourcePath">來源檔完整路徑</param>
/// <param name="outDir">輸出資料夾</param>
/// <param name="filter">轉換濾鏡,例如 "ods",或含濾鏡名稱的 "xlsx:Calc MS Excel 2007 XML"</param>
public static bool ConvertFile(string sourcePath, string outDir, string filter)
{
string sofficePath = @"C:\Program Files\LibreOffice\program\soffice.exe";
IntPtr primaryToken = IntPtr.Zero;
IntPtr logonToken = IntPtr.Zero;
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();

string userName = ConfigurationManager.AppSettings["SystemAccount"];
string password = ConfigurationManager.AppSettings["SystemPassword"];
string domain = ".";

try
{
XLogFile.WriteLog($"原始執行身分: {System.Security.Principal.WindowsIdentity.GetCurrent().Name}");

// 1) 取得使用者權杖(批次登入)
if (!LogonUser(userName, domain, password,
LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, out logonToken))
{
int err = Marshal.GetLastWin32Error();
throw new Win32Exception(err, $"LogonUser 失敗,錯誤碼 {err}");
}

// 2) 轉成 primary token,CreateProcessAsUser 只吃主要權杖
var sa = new SECURITY_ATTRIBUTES();
sa.Length = Marshal.SizeOf(sa);
if (!DuplicateTokenEx(logonToken, MAXIMUM_ALLOWED, ref sa,
SecurityImpersonation, TokenPrimary, out primaryToken))
{
int err = Marshal.GetLastWin32Error();
throw new Win32Exception(err, $"DuplicateTokenEx 失敗,錯誤碼 {err}");
}

// 3) 用主要權杖啟動 soffice,子行程才會真的以具名帳號身分執行
string args = BuildArguments(sourcePath, outDir, filter);
string commandLine = $"\"{sofficePath}\" {args}";
XLogFile.WriteLog($"完整命令: {commandLine}");

var si = new STARTUPINFO();
si.cb = Marshal.SizeOf(si);
si.lpDesktop = @"winsta0\default";

bool ok = CreateProcessAsUser(
primaryToken, null, commandLine,
ref sa, ref sa, false, CREATE_NO_WINDOW, IntPtr.Zero,
Path.GetDirectoryName(sourcePath), ref si, out pi);

if (!ok)
{
int err = Marshal.GetLastWin32Error();
throw new Win32Exception(err, $"CreateProcessAsUser 失敗,錯誤碼 {err}");
}

// 等待,並加上逾時保護避免卡死整個工作行程
const uint timeoutMs = 60_000;
uint waitResult = WaitForSingleObject(pi.hProcess, timeoutMs);
if (waitResult != 0) // 0 == WAIT_OBJECT_0
XLogFile.WriteLog($"警告:soffice 在 {timeoutMs}ms 內未結束(waitResult={waitResult})");

// 4) 檢查輸出檔
string expected = ResolveExpectedOutput(sourcePath, outDir, filter);
if (File.Exists(expected))
{
XLogFile.WriteLog($"檔案轉換成功: {expected}");
return true;
}

XLogFile.WriteLog($"警告:找不到預期輸出檔 {expected}");
return false;
}
catch (Exception ex)
{
XLogFile.WriteLog($"轉換過程發生錯誤: {ex}");
throw;
}
finally
{
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
if (logonToken != IntPtr.Zero) CloseHandle(logonToken);
}
}

// 組命令列:每次給一個獨立的 user profile 目錄,避免多執行緒共用 profile 互卡
private static string BuildArguments(string sourcePath, string outDir, string filter)
{
string profileDir = Path.Combine(
ConfigurationManager.AppSettings["LibreOfficeProfileRoot"] ?? @"D:\temp\lo_profiles",
Guid.NewGuid().ToString("N"));

// soffice 接受的是 file:// 形式的 UserInstallation
string profileUri = new Uri(profileDir).AbsoluteUri;

var sb = new StringBuilder();
sb.Append($"-env:UserInstallation={profileUri} ");
sb.Append("--headless --norestore --invisible ");
sb.Append($"--convert-to \"{filter}\" ");
sb.Append($"--outdir \"{outDir}\" ");
sb.Append($"\"{sourcePath}\"");
return sb.ToString();
}

// 推導預期輸出檔名:濾鏡字串可能是 "xlsx:Calc MS Excel 2007 XML",
// 真正的副檔名只取冒號前那段
private static string ResolveExpectedOutput(string sourcePath, string outDir, string filter)
{
int colon = filter.IndexOf(':');
string ext = colon >= 0 ? filter.Substring(0, colon) : filter;
string fileName = Path.GetFileNameWithoutExtension(sourcePath) + "." + ext;
return Path.Combine(outDir, fileName);
}
}

幾個和原始坑直接相關的修正點:

  • **改用 CreateProcessAsUser**:這是讓 soffice 真正以具名帳號跑的唯一可靠途徑,Process.Start 在模擬情境下做不到。
  • 濾鏡與副檔名分開處理--convert-tofilter 參數可以只給副檔名(ods),也可以帶完整濾鏡名稱(xlsx:Calc MS Excel 2007 XMLcsv:Text - txt - csv (StarCalc))。輸出檔的副檔名只由冒號前那段決定,所以推導預期檔名時要先切掉冒號後面,直接把整串接在檔名後一定找不到檔。
  • 每次給獨立 user profile-env:UserInstallation 指向每次唯一的目錄,避免多個轉檔同時跑時共用同一份 profile 互相鎖死(這在 Windows Server 上呼叫多次 soffice 卡住的情形很常見)。
  • 加逾時保護WaitForSingleObject 帶逾時,soffice 萬一卡住不會把整個工作行程拖著一起死。

5. Web.config 設定與密碼加密

帳號設定放在 appSettings

1
2
3
4
5
6
7
<configuration>
<appSettings>
<add key="SystemAccount" value="您的使用者帳號" />
<add key="SystemPassword" value="您的密碼" />
<add key="LibreOfficeProfileRoot" value="D:\temp\lo_profiles" />
</appSettings>
</configuration>

這裡有個一定要提醒的安全問題:不要把帳號密碼用明文丟在 Web.config 裡就上線。Web.config 很容易被一起 commit 進版控、被同事或備份帶走,IIS 設定一外洩等於帳密一起外洩。

正式環境請用 ASP.NET 內建的設定保護(protected configuration)把 appSettings 區段加密。在伺服器上用 aspnet_regiis 加密:

1
aspnet_regiis -pef "appSettings" "C:\inetpub\wwwroot\YourApp" -prov "DataProtectionConfigurationProvider"
  • DataProtectionConfigurationProvider 走的是 Windows DPAPI,加密金鑰綁機器,適合單機部署。
  • 若是 Web farm 多台機器共用設定,改用 RsaProtectedConfigurationProvider 並把 RSA 金鑰容器匯出到各台機器。

加密後 appSettings 在檔案裡是密文,但程式照 ConfigurationManager.AppSettings["SystemAccount"] 讀取時,ASP.NET 會自動解密,程式碼一行都不用改。

更乾淨的做法是連密碼都不要落地——改用 Windows 群組受管理服務帳戶(gMSA),讓集區直接以該身分跑,連 SystemPassword 都省掉。本文為了示範 CreateProcessAsUser 的完整流程保留具名帳號方案,但實務上 gMSA 是更省心的選擇。

6. 使用方式

1
2
3
4
5
6
7
8
9
10
11
// 轉成 ODS
LibreOfficeConverter.ConvertFile(
@"D:\Source\test.xlsx", // 來源
@"D:\Destination", // 輸出資料夾
"ods"); // 濾鏡

// 轉成 xlsx 並指定濾鏡名稱
LibreOfficeConverter.ConvertFile(
@"D:\Source\report.csv",
@"D:\Destination",
"xlsx:Calc MS Excel 2007 XML");

完整檢查清單

  1. IIS 設定

    • 應用程式集區「載入使用者設定檔」= True
    • 集區識別維持 ApplicationPoolIdentity(轉檔身分另由具名帳號負責)
  2. 具名帳號的使用者權限指派(針對 SystemAccount

    • 取代處理程序層級權杖
    • 調整處理程序的記憶體配額
    • 以批次工作登入(對應程式碼的 LOGON32_LOGON_BATCH
  3. 資料夾權限(讀寫執行,給具名帳號)

    • LibreOffice 安裝目錄
    • 來源 / 目標資料夾
    • 暫存資料夾
    • LibreOffice user profile 根目錄
  4. Web.config

    • SystemAccount / SystemPassword 設定正確
    • 上線前已用 aspnet_regiis 加密 appSettings(不留明文)

常見問題排除

  1. 程式跑完卻沒輸出、log 顯示身分已切換
    檢查你是不是還在用 Process.Start。那條路線在 ASP.NET 下不會把模擬傳給子行程,soffice 仍以集區身分跑。改用 DuplicateTokenExCreateProcessAsUser

  2. CreateProcessAsUser 回傳 false,錯誤碼 1314
    1314 是 ERROR_PRIVILEGE_NOT_HELD,代表具名帳號缺「取代處理程序層級權杖」或「調整處理程序的記憶體配額」。回到第 2 步補齊。

  3. LogonUser 失敗,錯誤碼 1385
    ERROR_LOGON_TYPE_NOT_GRANTED,登入類型和帳號被授予的權限對不上。本文用 LOGON32_LOGON_BATCH,就要確認帳號有「以批次工作登入」。

  4. 多次轉檔時 soffice 卡住沒回應
    多半是共用 user profile 造成鎖定。確認每次都用獨立的 -env:UserInstallation,並給逾時保護。

  5. 找不到輸出檔,但檔案其實有產生
    檢查預期檔名的推導:濾鏡若帶冒號(如 xlsx:...),副檔名只取冒號前那段,別把整串接在檔名後。

結論

這篇要修正的核心觀念只有一個:在 ASP.NET 裡,ImpersonateLoggedOnUserProcess.Start 不會讓子行程帶著被模擬的身分跑,因為模擬作用在執行緒、子行程拿的是宿主行程的主要權杖。要讓 soffice.exe 真的以指定帳號執行,必須走 LogonUserDuplicateTokenEx(轉主要權杖)→ CreateProcessAsUser,並替這個帳號補上「取代處理程序層級權杖」「調整處理程序的記憶體配額」兩項權限。

其餘像濾鏡決定副檔名、每次給獨立 profile、密碼別明文落地,都是部署到真實環境後遲早會撞上的細節。把這些一次處理掉,IIS 上的 LibreOffice 轉檔才會穩定可靠。