Windows Service 紀錄帳號登入、登出、鎖定、解除鎖定狀態至 Database

  • 277
  • 0
  • 2021-11-24

Windows Service 紀錄帳號登入(Logon)、登出(Logoff)、鎖定(Lock)、解除鎖定(Unlock)狀態至 Database

因為疫情的關係,公司預備 Working from Home 計畫,當然免不了就是要記錄員工電腦使用狀況,以作為確認居家辦公狀態的輔助,第一次寫 Windows Service,於是寫一篇開發筆記。

情境

使用公司 AD 帳號登入電腦,透過 AD 帳號尋找記錄在鼎新 EasyFlow 上的員工編號(EasyFlow 員工表單上的 email @前面就是 AD 帳號),將員工工號、員工姓名、Session 狀態、電腦名稱、時間/日期、版本 Insert 到 txt,再逐行讀取 txt 文件寫入資料庫,寫入到資料庫的 txt 移到另一個資料夾,並修改檔名留做紀錄。如果外點電腦連不到公司內網,可再下一次連到內網時將之前的記錄寫入資料庫。

2020/04/17 更新

  1. 因外點電腦無法與公司內部資料庫連接,所以外點電腦的 Session 會抓不到員工工號,經討論後決定把員工編號拿掉,變成 Null,之後要 join 其他 Table 就用 AD 帳號。
  2. Table 中加上版本編號。
  3. 若有多個帳號登入同一台電腦,一開始使用 ManagementObjectSearcher 只會抓到第一位登入者 AD 帳號,故使用 Windows API 抓取每個登入者 AD 帳號。

前置準備

要開發 Windows Service,首先要了解它的功用,以及基本開發環境建置,找了幾篇不錯的網站作為參考(另開視窗):

教學課程:建立 Windows 服務應用程式

[.Net / C#] 撰寫在背景執行的服務 (Windows Service)

當然也要新增 Table,以下為這次開發的 Table:

Table Name:ClientPCLoginStatus

資料行名稱資料類型允許 Null
emplidvarchar(50)Y
emplaccountvarchar(50)Y
eventvarchar(50)Y
ComputerNamevarchar(50)Y
datetimedatetimeY
versionvarchar(50)Y

開發

Windows Service 無法直接按 F5 偵錯,找到一篇文章幫助偵錯:如何對 [Windows 服務] 與 [安裝專案的自訂動作] 進行除錯

在環境建置好後,最開始在 ComputerSession.cs 看到 ComputerSession()、OnStart()、OnStop() 這三個 function。

在 class ComputerSession { } 裡面先定義會用到的變數:

public partial class ComputerSession : ServiceBase
{
    #region 初始化設定
    public static string ComputerName = Environment.MachineName; // 取得電腦名稱
    public static string logPath = @"C:\SystemLogs.txt";  // 定義log路徑
    public static string newPath = @"C:\SystemLogs_old\";  // 定義移動資料夾
    public static string version = Assembly.GetExecutingAssembly().GetName().Version.ToString();  // 取得版本編號
    public static string event_id = "";  // 定義事件
    public System.Timers.Timer timer;  // 定義計時器

    public ComputerSession()
    {
        InitializeComponent();

        // 允許程式抓Session狀態
        this.CanHandleSessionChangeEvent = true;
        this.AutoLog = true;
        this.CanHandlePowerEvent = true;
        this.CanPauseAndContinue = true;
        this.CanShutdown = true;
        this.CanStop = true;
        }
    #endregion
}

程式會先進到 OnStart() 裡面,建立 Thread 來執行任務,並建立 log file 確認服務是否啟動;程式結束就會執行 OnStop() 事件,意味著當服務停止時,清空計時器。

using System.IO;
using System.Threading;

#region OnStart(), OnStop()
protected override void OnStart(string[] args)
{
    Thread.Sleep(31000);  //Simulates processing time leading to a time out.

    // 服務執行時若沒有檔案路徑,新增txt
    if (!File.Exists(logPath))
    {
                File.Create(logPath).Close();
    }
}

protected override void OnStop()
{
    // 當服務停止時,清空計時器
    if (timer != null)
    {
        timer.Stop();
        timer.Close();
        timer = null;
    }
}
#endregion

接下來就是重點囉!要抓到每一位登入者 AD 帳號資訊,必須串接 Windows API,詳細 API 可參考官方文件

using System.Runtime.InteropServices;

#region 串接WindowsApi
// 串接原因: 為了安裝方便,Account選擇「LocalSystem」,但是服務抓到的username=SYSTEM,而ManagementObjectSearcher無法取得多重使用者登入資訊,所以改用Win API抓當前使用者
[DllImport("Wtsapi32.dll")]
protected static extern void WTSFreeMemory(IntPtr pointer);
[DllImport("Wtsapi32.dll")]
protected static extern bool WTSQuerySessionInformation(IntPtr hServer, int sessionId, WTSInfoClass wtsInfoClass, out IntPtr ppBuffer, out uint pBytesReturned);
#endregion

接下來就要列舉 Windows API 資訊,之後就可以拿這些列舉取得 API 內容,何謂列舉?我爬到一篇寫得還不錯的文章,提供給大家參考:C#資料群集系列菜單-『列舉(Enum)』

#region 列舉Win API資訊
// enum 為列舉,針對某資料片段只允許特定的資料行別
// 詳見可參考: https://dotblogs.com.tw/h091237557/2014/05/19/145177
public enum WTSInfoClass
{
    WTSInitialProgram,
    WTSApplicationName,
    WTSWorkingDirectory,
    WTSOEMId,
    WTSSessionId,
    WTSUserName,
    WTSWinStationName,
    WTSDomainName,
    WTSConnectState,
    WTSClientBuildNumber,
    WTSClientName,
    WTSClientDirectory,
    WTSClientProductId,
    WTSClientHardwareId,
    WTSClientAddress,
    WTSClientDisplay,
    WTSClientProtocolType,
    WTSIdleTime,
    WTSLogonTime,
    WTSIncomingBytes,
    WTSOutgoingBytes,
    WTSIncomingFrames,
    WTSOutgoingFrames,
    WTSClientInfo,
    WTSSessionInfo
}
#endregion

當 user 改變電腦 Session,該如何抓到 Session 呢?在 C# 裡面有一個 OnSessionChange 事件,在每一次 Session 改變時就抓一次登入者 AD 帳號,並在 txt 中 Append 每一筆紀錄。詳細介紹可參考微軟官方文件 ServiceBase.OnSessionChange(SessionChangeDescription) 方法

#region OnSessionChange()
// 當User Session改變時執行以下語法
protected override void OnSessionChange(SessionChangeDescription changeDescription)
{
    string emplaccount = GetUsername(changeDescription.SessionId);  // 取得登入者資訊
    //string emplid = getEmplid(emplaccount);  // 呼叫 getEmplid() 取得員工工號

    // TEST
    /**/
    if (!File.Exists(logPath))
    {
        File.Create(logPath).Close();
    }

    switch (changeDescription.Reason)
    {
        case SessionChangeReason.SessionLogon:
            event_id = "Logon";
            File.AppendAllText(logPath, emplaccount + "," + event_id + "," + ComputerName + "," + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "," + version + Environment.NewLine);
            break;
        case SessionChangeReason.SessionLogoff:
            event_id = "Logoff";
            File.AppendAllText(logPath, emplaccount + "," + event_id + "," + ComputerName + "," + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "," + version + Environment.NewLine);
            break;
        case SessionChangeReason.RemoteConnect:
            event_id = "RemoteConnect";
            File.AppendAllText(logPath, emplaccount + "," + event_id + "," + ComputerName + "," + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "," + version + Environment.NewLine);
            break;
        case SessionChangeReason.RemoteDisconnect:
            event_id = "RemoteDisconnect";
            File.AppendAllText(logPath, emplaccount + "," + event_id + "," + ComputerName + "," + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "," + version + Environment.NewLine);
            break;
        case SessionChangeReason.SessionLock:
            event_id = "Lock";
            File.AppendAllText(logPath, emplaccount + "," + event_id + "," + ComputerName + "," + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "," + version + Environment.NewLine);
            break;
        case SessionChangeReason.SessionUnlock:
            event_id = "Unlock";
            File.AppendAllText(logPath, emplaccount + "," + event_id + "," + ComputerName + "," + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "," + version + Environment.NewLine);
            break;
       default:
            break;
    }

    // 呼叫 recordInfo(),傳入emplid, emplaccount, event_id, ComputerName
    recordInfo(emplaccount, event_id, ComputerName);

    base.OnSessionChange(changeDescription);
}
#endregion

那 GetUsername(changeDescription.SessionId) 的用途是什麼?就是用 API 實際抓每個狀態 Session 改變時當下的登入者 AD 帳號。詳細可見下方註解說明。

#region GetUsername()
/// <summary>
/// 獲取當前登錄用戶(可用於管理員身份運行)
/// </summary>
/// <returns></returns>
public static string GetUsername(int sessionId)
{
    IntPtr buffer;  // 定義指標,IntPtr為指標類型,可參考官方文件: https://docs.microsoft.com/zh-tw/dotnet/api/system.intptr?view=netframework-4.8
    uint strLen;  // 定義不帶符號的int型態
    string username = "SYSTEM";  // 預設username=SYSTEM

    // WTSQuerySessionInformation(hServer, SessionId, WTSInfoClass, *ppBuffer, *pBytesReturned)
    // hServer: 作用目標
    // SessionId: Windows Session ID
    // WTSInfoClass: WTS_INFO_CLASS列舉的值,指示對WTSQuerySessionInformation()中要用到的值
    // *ppBuffer: 存放修改過的指標內容
    // *pBytesReturned: 接收*ppBuffer回傳的資料大小
    // 可參考官方文件: https://docs.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtsquerysessioninformationa

    // IntPtr.Zero: 初始化指標
    // WTSInfoClass.WTSDomainName: WTSInfoClass列舉中的username
    // out buffer修改傳入的參數'buffer',可參考: https://dotblogs.com.tw/erictsaiblog/2015/05/10/151238
    if (WTSQuerySessionInformation(IntPtr.Zero, sessionId, WTSInfoClass.WTSUserName, out buffer, out strLen) && strLen > 1)
    {
        // Marshal 類別: 用於配置 Unmanaged 記憶體、複製 Unmanaged 記憶體區塊和將 Managed 類型轉換為 Unmanaged 類型
        // PtrToStringAnsi: 將指標轉換為編碼為ANSI的字串
        username = Marshal.PtrToStringAnsi(buffer);

        // WTSFreeMemory(): 釋放記憶體
        WTSFreeMemory(buffer);
    }

    return username;
}
#endregion

接下來就是將 txt 每行資料寫入到資料庫,在寫入的時候要先判斷當下電腦是否能連到公司內網(也就是資料庫本身),於是用 System.Net.NetworkInformation 中 Ping() 方法去判斷。 如果能 ping 成功,就將 txt 資料逐行讀取並寫入資料庫,整份文件全部寫到資料庫後,再把文件複製到其他資料夾,並刪除原本的檔案;相反的,如果當下 ping 不到主機,就保留 txt 文件,等到下一次 Session 狀態改變時能連到公司內網(資料庫),再將之前 txt 的紀錄寫入到資料庫中。

在 ConfigurationManager 我卡了一下,雖然已經有 using System.Configuration;,但找不到 ConfigurationManager,查了一下原因出在 方案總管參考(右鍵)加入參考搜尋 configuration將 System.Configuration 打勾,即可加入 ConfigurationManager,詳細可參考 如何透過 C# 類別庫讀取 Web.config 或 App.config 的參數設定值

整段 code 如下:

using System.Data.SqlClient;
using System.Net.NetworkInformation;

#region recordInfo()
// 將Session Log寫入txt,若能ping到主機則將txt內容Insert到資料庫
public void recordInfo(string emplaccount, string event_id, string ComputerName)
{
    Ping ping = new Ping();

    // 定義ping目標為XXX.XXX.XXX.XXX:80
    // 參考官方文件: https://docs.microsoft.com/zh-tw/dotnet/api/system.net.networkinformation.ping.send?view=netframework-4.8
    PingReply pingReply = ping.Send("XXX.XXX.XXX.XXX", 80);

    try
    {
        // 檢測網路是否能ping到主機(XXX.XXX.XXX.XXX)
        if (pingReply.Status == IPStatus.Success)
        {
            StreamReader streamReader = new StreamReader(logPath);  // 設定讀取串流
            string line = null;

            // 若串流讀取到的行數不為空值,將txt內容Insert到資料庫
            while ((line = streamReader.ReadLine()) != null)
            {
                // 將每行資料用','分割,存入陣列
                string[] str = line.Split(',');
                SqlConnection dataConnection = new SqlConnection(null);  // 新增SqlConnection
                dataConnection.ConnectionString = System.Configuration.ConfigurationManager.ConnectionStrings["TEST"].ConnectionString;  // 連線詳細資訊參考App.config
                string strMsgInsert = @"INSERT INTO ClientPCLoginStatus ([emplaccount],[event],[ComputerName],[datetime],[version]) VALUES(@emplaccount, @event_id, @ComputerName, @datetime, @version) ";
                // SQL INSERT 語法: INSERT INTO  [table] ([欄位名1],[欄位名2],[欄位名3],....) VALUES (@值1, @值2, @值3,...)

                SqlCommand mySqlCmd = new SqlCommand(strMsgInsert, dataConnection);  // C# SQL Command 物件 SqlCommand( SQL語法 , SqlConnection )
                                                                                             // insert into DB

                try
                {
                    dataConnection.Open();

                    //mySqlCmd.Parameters.AddWithValue("@emplid", DBNull.Value);
                    mySqlCmd.Parameters.AddWithValue("@emplaccount", str[0]);
                    mySqlCmd.Parameters.AddWithValue("@event_id", str[1]);
                    mySqlCmd.Parameters.AddWithValue("@ComputerName", str[2]);
                    mySqlCmd.Parameters.AddWithValue("@datetime", str[3]);
                    mySqlCmd.Parameters.AddWithValue("@version", str[4]);

                    mySqlCmd.ExecuteNonQuery();
                    dataConnection.Close();
                }
                catch (Exception ex)
                {
                    throw new Exception(ex.ToString());
                }
                finally
                {
                    mySqlCmd.Cancel();
                    dataConnection.Close();
                    dataConnection.Dispose();
                }
            }
            streamReader.Close();
            streamReader.Dispose();

            string newLogPath = @"C:\SystemLogs_old\SystemLogs_" + emplaccount + "_" + DateTime.Now.ToString("yyyyMMddhhmmssfff") + ".txt";  // 定義移動的文件檔名

            // 若目錄下沒有[移動資料夾],新增資料夾,並將檔案移動到該路徑,並重新命名
            if (!(Directory.Exists(newPath)))
            {
                Directory.CreateDirectory(newPath);
            }
            File.Copy(logPath, newLogPath);
            File.Delete(logPath);
        }
    }
    catch (Exception ex)
    {
        throw new Exception(ex.ToString());
    }
}
#endregion

以上,程式大致完成。

 

安裝

接下來就要進行打包安裝,如果有寫過 Windows Form 大概就知道該怎麼打包,大致簡述流程:

  1. Visual Studio 現有專案中加入安裝專案
  2. 專案輸出
  3. Windows Service 安裝
  4. 啟動 Windows Service。

Visual Studio 現有專案中加入安裝專案、專案輸出

這部份可參考:Windows Form程式 如何製作安裝檔?,就不多做說明。

注意:Visual Studio 2017 以後(含),請用 Visual Studio Installer 安裝

Windows Service 安裝

網路上有許多做法,包含 製作 bat 指令安裝,或是用 InstallUtil.exe 安裝,但我個人很懶,直接在 VS 方案總管>安裝專案中,按右鍵 [建置]>右鍵 [安裝],如下圖:

等到真正需要安裝檔,檔案會在 \安裝專案資料夾\Debug\安裝檔.msi

啟動 Windows Service

[開始]>輸入 service>就能找到服務程式,直接將安裝好的服務右鍵→啟動即可。

 

後記

由於公司使用 AD 帳號,在 ProcessInstaller 的 Account 屬性我只能選 UserEnvironment.UserName 才能抓到 user 的網域名稱,如果屬性改為 LocalService、NetworkService、LocalSystem,都無法抓到真正的 AD 登入名稱,後來我嘗試安裝,就必須要輸入登入者 AD 帳號密碼,對於 MIS 或是 user 自己安裝都有些不便,目前還在想其他解決方案,如果有想到會再補充上來。

2020/04/17 補充

使用 Windows API 之後,確實就能抓到每個登入者的 AD 帳號,之前所有的問題都已經排除。現在就差工號抓不到,不然就算完美了 XD

如果有使用鼎新 EasyFlow 的朋友,可以討論看看要如何用 AD 帳號抓員工工號吧~

只有自己摸索過,才能內化轉換成力量。