# 甲、概述與資源 ## 一、概述 要向 SAS Viya 做 HTTP 請求,必須攜帶 token,這個 token 可以在登入後從網頁取來用,但網頁式取法不適合程式化的 API,另外雖然有程式化取法但傳送帳密的作法在資安上是不好的方案。 以下筆記的目標,是為了 WEB API 以程式化的做法向 SAS 取 token 以便第二次做請求,在 OAuth 2.0 的架構下要能達成這個目標,必須先有 client credential,再視需求是否有另外的變數(username+password 或 authorization code),然而要建立 client,必須帳號擁有 SASAdministrators 等 scope,所以下面的筆記從最初的設定群組開始,若已經完成一個階段,可以跳過往後看。 流程大致如下: 1. 管理者將帳號加入足夠權限建立 client 的群組 2. 一般帳號網頁取得 token 3. 使用 token 透過 API 註冊 client 4. 用 client credential 透過 API 取得 token ## 二、文件資源 * [SAS for Developers : SAS REST APIs: Authentication & Authorization](https://developer.sas.com/reference/viya35/auth/) * [SAS Blogs : Authentication to SAS Viya: a couple of approaches](https://blogs.sas.com/content/sgf/2023/02/07/authentication-to-sas-viya/) * [SAS Blogs : Building custom apps on top of SAS Viya, Part Three: Choosing an OAuth flow](https://blogs.sas.com/content/sgf/2020/09/14/building-custom-apps-on-top-of-sas-viya-part-three-choosing-an-oauth-flow/) * [SAS Blogs : Building custom apps on top of SAS Viya, Part Four: Examples](https://blogs.sas.com/content/sgf/2020/09/25/building-custom-apps-on-top-of-sas-viya-part-four-examples/) * [SAS Supports : OpenID Connect Opens the Door to SAS® Viya® APIs](https://support.sas.com/resources/papers/proceedings18/1737-2018.pdf) # 乙、群組權限 ## 一、Insufficient scope 如果請求 OAuth 2.0 的一些資源,例如 GET 或 POST clients,碰到 **error** 是 `insufficient_scope` 或 **error_description** 是 `Insufficient scope for this resource`,表示夾帶的 token 是由權限不足的帳號所產生,必須先加入足夠的群組。下圖就是由權限不足的帳號產生的 token,來做請求的結果: ![](https://hackmd.io/_uploads/rken8TmWp.png) 上圖有提示 token 必須由 `uaa.admin`、 `clients.read`、 `clients.admin`、 `SASAdministrators`、 `zones.uaa.admin` 其中之一的 scope 的帳號來產生,才能請求這個資源。 下面示範將帳號加入 `SASAdministrators`。 ## 二、加入群組 瀏覽器網址輸入 `~/` 或 `~/SASLogon/login`,登入==管理者帳號==: ![](https://hackmd.io/_uploads/ByfEPdQZ6.png) 點選「是」來使用管理者權限繼續頁面: ![](https://hackmd.io/_uploads/BJqbydXZp.png) 點選左上角展開選單: ![](https://hackmd.io/_uploads/S13c1uXWp.png) 點選下方管理→管理環境: ![](https://hackmd.io/_uploads/BkX0JdQ-6.png) 點選系統→使用者(若沒有文字,下方有一個 `>>` 展開瀏覽列,點選後會展開): ![](https://hackmd.io/_uploads/B1QIe_X-6.png) 自訂群組點選 SAS Administrators: ![](https://hackmd.io/_uploads/HyYhb_7-T.png) 結果會在右邊,點選編輯: ![](https://hackmd.io/_uploads/BJrYG_mbT.png) 選了要加入的人後,點中間的 `+>` 增加,選取的識別身分出現後,右邊點選確定: ![](https://hackmd.io/_uploads/BJ3S4_7b6.png) 完成後右上角登出: ![](https://hackmd.io/_uploads/rkcLSOXZp.png) # 丙、註冊 client 使用 API 建立 client,需要 token 且只要做一次就好,不需要反覆程式化取,所以用網頁取即可,若環境不方便開啟瀏覽器,也是有 API curl 的方法。 ## 一、取最初 token 方法有網頁取和 API 取,由環境適合哪一項來判斷,以下 1. 和 2. 選一個做即可。 ### 1. 網頁取最初 token 瀏覽器網址輸入 `~/` 或 `~/SASLogon/login`,登入 **scope 足夠的**==一般帳號==。 ![](https://hackmd.io/_uploads/ByfEPdQZ6.png) 不需要套用管理者權限。登入成功後在網址列輸入: `~/SASLogon/oauth/authorize?client_id=sas.cli&response_type=token` 出現下面的畫面後,會重導向到另一個路徑,將網址列取出來: ![](https://hackmd.io/_uploads/B1rvFOQZT.png) 截取 `&access_token=` 後面一直到 `&expires_in=` 之前的文字,複製出來: ![](https://hackmd.io/_uploads/rk56iuQZa.png) ### 2. API 取最初 token 填入帳號與密碼做以下請求: * -X 方法 `POST` * 請求路徑 `~/SASLogon/oauth/token` * -u 使用 `sas.cli:`,要注意是冒號結尾 * -H 夾帶 Header 變數 `Content-Type: application/x-www-form-urlencoded` * -d 夾帶 Data 變數 `grant_type=password&username=帳號&password=密碼` * `grant_type` 指定為 `password` * `username` 使用 scope 足夠的帳號 * `password` 以上帳號的登入密碼 參考語法: ```bash! curl -X POST "主機/SASLogon/oauth/token" -u "sas.cli:" -H "Content-Type:application/x-www-form-urlencoded" -d "grant_type=password&username=帳號&password=密碼" ``` ![image](https://hackmd.io/_uploads/ByaIN8LUR.png) 取出回應 JSON 中 `access_token` 的內容(參考上圖黃框內容)。 ## 二、查看已有 client 使用 `GET` 方法可對路徑 `~/SASLogon/oauth/clients` 取得目前所有 client 的資料,可用來查看之前之後的變化。 ### 1. cURL 檢視 * -X 方法 `GET` * 請求路徑 `~/SASLogon/oauth/clients` * -H 夾帶 Header 變數 `Authorization: Bearer ` 加上前面網頁取得的 token 語法: ```bash! curl -X GET "主機/SASLogon/oauth/clients" -H "Authorization: Bearer 通行證" ``` ![](https://hackmd.io/_uploads/Skt2C_mZa.png) 送出後會得到回傳的 JSON 字串,列出所有 client 結果。 ### 2. Postman 檢視 * 方法 `GET` * 請求路徑 `~/SASLogon/oauth/clients` * 在`Authorization` 分頁,Type 選 `Bearer Token`,右邊 Token 填上前面網頁取得的 token ![](https://hackmd.io/_uploads/H1o1ZKmWp.png) 回應 Body 得 JSON 的 client 結果。 ## 三、註冊新的 client 使用 `POST` 方法可對路徑 `~/SASLogon/oauth/clients` 建立新的 client,須要傳入以下變數: * `client_id`:client 名稱,下面以 `app.demo` 為範例(不一定要 `app.` 開頭,此為文件建議方便管理的命名) * `client_secret`:client 密碼,下面以 `myPassword` 為範例 * `scope`:權限範圍,至少要有 `"openid"`,以陣列 `[]` 放入 * `authorized_grant_types`:授權 flow 的類型,以陣列 `[]` 放入,有下列幾種: * `"client_credentials"` * `"password"` * `"refresh_token"` * `"authorization_code"` * `redirect_uri`:完成後導向路徑,固定填入 `"urn:ietf:wg:oauth:2.0:oob"` ### 1. cURL 新增 * -X 方法 `POST` * 請求路徑 `~/SASLogon/oauth/clients` * -H 夾帶 Header 變數 `Authorization: Bearer ` 加上前面網頁取得的 token * -H 夾帶 Header 變數 `Content-Type: application/json` * -d 夾帶 Data 資料,以 JSON 格式傳入 client_id、client_secret、scope、authorized_grant_types、redirect_uri ==Windows== 使用 curl 時 JSON 資料裡雙引號要從 `"` 改成 `\"`,例如: `-d "{\"client_id\":\"app.demo\",\"client_secret\":\"myPassword\"}"` ==Linux== 使用 curl 時外面用單引號 `'` 則裡面可使用雙引號 `"`,例如: `-d '{"client_id":"app.demo","client_secret":"myPassword"}'` 以下 `authorized_grant_types` 以 `client_credentials` 為例,如果要使用其他 flow,修改或加入其他類型。 以下 Windows 語法: ```bash! curl -X POST "主機/SASLogon/oauth/clients" -H "Authorization: Bearer 通行證" -H "Content-Type: application/json" -d "{\"client_id\":\"app.demo\",\"client_secret\":\"myPassword\",\"scope\":[\"openid\"],\"authorized_grant_types\":[\"client_credentials\"],\"redirect_uri\":\"urn:ietf:wg:oauth:2.0:oob\"}" ``` 參考 Linux 語法: ```bash! curl -X POST "主機/SASLogon/oauth/clients" -H "Authorization: Bearer 通行證" -H "Content-Type: application/json" -d '{"client_id":"app.demo","client_secret":"myPassword","scope":["openid"],"authorized_grant_types":["client_credentials"],"redirect_uri":"urn:ietf:wg:oauth:2.0:oob"}' ``` ![](https://hackmd.io/_uploads/HJucKYQW6.png) 送出後會回傳新增的 client 的資料,可再用 `GET` 做確認有沒有新增進去。 ### 2. Postman 新增 * 方法 `POST` * 請求路徑 `~/SASLogon/oauth/clients` * 在`Authorization` 分頁,Type 選 `Bearer Token`,右邊 Token 填上前面網頁取得的 token * 在 `Body` 分頁選擇 `raw`,右邊下拉選 `JSON`,填入對應的變數 ![](https://hackmd.io/_uploads/H12wCKQb6.png) 送出後會回傳新增的 client 的資料,可再用 `GET` 做確認有沒有新增進去。 ## 四、刪除已註冊 client 沒有 `PUT` 方法可修改,所以做錯了或要異動 client 設定,需要使用 `DELETE` 方法刪除,再使用 `POST` 方法重新新增。 * 方法 `DELETE` * 請求路徑 `~/SASLogon/oauth/clients/` 加上 `client_id` * 夾帶 Header 變數 `Authorization: Bearer ` 加上前面網頁取得的 token;若 Postman 則於 Authorization 分頁的 Type 選擇 Bearer Token,填入取得 token 下面示範使用 Postman 刪除 `app.demo` ,路徑是 `~/SASLogon/oauth/clients/app.demo`: ![](https://hackmd.io/_uploads/BytujFQZp.png) 成功會回傳刪除的 client 的資料,若不存在則回傳空字串。 # 丁、API 取 token 有了 client 後,依照新增時設定的 `authorized_grant_types` 為何,對應取得 token 的方法,和傳入的變數。 * client_credentials,最基本要傳入 `client_id` 和 `client_secret`,curl 使用 -u,Postman 在 Authorization 分頁選擇 `Basic Auth`。 * password,要多傳送 `username` 和 `password` 兩個變數 * refresh_token,要多傳送 `refresh_token` 變數 * authorization_code,要多傳送 `code` 變數 ## 一、cURL 取 token cURL 有兩個寫法,一個是用 `-u` 傳送驗證資料,另一個是在 `-d` 放入驗證資料。 ### 1. -u 驗證 * -X 方法 `POST` * 請求路徑 `~/SASLogon/oauth/token` * -H 夾帶 Header 變數 `Content-Type: application/x-www-form-urlencoded` * -d 夾帶 Data 資料 `grant_type=client_credentials` * -u 附上驗證資料 `$client_id$:$client_secret$`,以冒號 `:` 分隔 ![](https://hackmd.io/_uploads/Skd7Dcm-p.png) 送出後會得到一包 JSON,其中 `access_token` 即為之後需要的 token。 參考語法: ```bash! curl -X POST "主機/SASLogon/oauth/token" -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=client_credentials" -u "app.demo:myPassword" ``` ### 2. -d 驗證 * -X 方法 `POST` * 請求路徑 `~/SASLogon/oauth/token` * -H 夾帶 Header 變數 `Content-Type: application/x-www-form-urlencoded` * -d 夾帶 Data 資料 * `grant_type=client_credentials` * `client_id=$client_id$` * `client_secret=$client_secret$` 送出後會得到一包 JSON,其中 `access_token` 即為之後需要的 token。 參考語法: ```bash! curl -X POST "主機/SASLogon/oauth/token" -H "Content-Type: application/x-www-form-urlencoded" -d "grant_type=client_credentials&client_id=app.demo&client_secret=myPassword" ``` ## 二、Postman 取 token * 方法 `POST` * 請求路徑 `~/SASLogon/oauth/token` * 在`Authorization` 分頁,Type 選 `Basic Auth`,右邊 Username 填上 client_id,前面示範為 `app.demo`,Password 填上 client_secret,前面示範為 `myPassword` * 在 `Header` 分頁,加入 KEY `Content-Type`,VALUE 為 `application/x-www-form-urlencoded` * 在 `Body` 分頁選擇 `x-www-form-urlencoded`,加入 KEY `grant_type`,VALUE 為 `client_credentials` Authorization 分頁 ![](https://hackmd.io/_uploads/Skhf45QW6.png) Headers 分頁 ![](https://hackmd.io/_uploads/SJkvNqmW6.png) Body 分頁 ![](https://hackmd.io/_uploads/rJfyB9XWa.png) 送出後會得到一包 JSON,其中 `access_token` 即為之後需要的 token。 ## 三、JavaScript 取 token ## 四、C# NET Core 取 token client_id 和 client_secret 要用冒號組成一個字串,透過 `System.Text.Encoding.UTF8.GetBytes` 和 `Convert.ToBase64String` 轉成 Base64 編碼字串。回應的 JSON 結果使用 `System.Text.Json` 的 `JsonDocument` 等物件方法取出 `access_token`,以下範例不做空值判斷與例外處理。 ### 1. 範例 * 給 `DefaultRequestHeaders` 做 `Add`,加入驗證用的資訊,前面加上 `"Basic "`(注意要空格)放在 `Authorization` 中來做請求。 * 另外用 `StringContent` 物件來傳入 form data,其中 `grant_type` 變數的值為 `client_credentials`。 ```csharp= using System.Text; using System.Text.Json; string token = await GetToken(); Console.WriteLine(token); Console.ReadKey(); async Task<string> GetToken() { HttpClient client = new HttpClient(); string url = "$host$/SASLogon/oauth/token"; byte[] byteCredential = Encoding.UTF8.GetBytes("app.demo:myPassword"); string stringCredential = Convert.ToBase64String(byteCredential); client.DefaultRequestHeaders.Add("Authorization", "Basic " + stringCredential); StringContent para = new StringContent( "grant_type=client_credentials", Encoding.UTF8, "application/x-www-form-urlencoded"); using (var response = await client.PostAsync(url, para)) { string? jsonResult = await response.Content.ReadAsStringAsync(); JsonDocument doc = JsonDocument.Parse(jsonResult); JsonElement root = doc.RootElement; if (root.TryGetProperty("access_token", out JsonElement access_token)) { return access_token.GetString(); } else { return "*"; } } } ``` ### 2. 範例 * 使用正統 `AuthenticationHeaderValue` 物件傳入憑證資訊,前綴是 `Basic`。 * 用 `KeyValuePair` 物件來傳入 form data,`grant_type` 變數的值是 `client_credentials`。若使用 `password` 或 `authorization_code` 或 `refresh_token` 等需要傳送其他變數的 flow,較適合用 `KeyValuePair` 寫法(可再參考 3. 範例)。 ```csharp= using System.Net.Http.Headers; using System.Text; using System.Text.Json; string token = await GetToken(); Console.WriteLine(token); Console.ReadKey(); async Task<string> GetToken() { HttpClient client = new HttpClient(); string url = "$host$/SASLogon/oauth/token"; byte[] byteCredential = Encoding.UTF8.GetBytes("app.demo:myPassword"); string stringCredential = Convert.ToBase64String(byteCredential); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", stringCredential); var paras = new List<KeyValuePair<string, string>>(); paras.Add(new KeyValuePair<string, string>("grant_type", "client_credentials")); var content = new FormUrlEncodedContent(paras); using (var response = await client.PostAsync(url, content)) { string? jsonResult = await response.Content.ReadAsStringAsync(); JsonDocument doc = JsonDocument.Parse(jsonResult); JsonElement root = doc.RootElement; if (root.TryGetProperty("access_token", out JsonElement access_token)) { return access_token.GetString(); } else { return "*"; } } } ``` ### 3. 範例 若註冊新增 client 時,`authorized_grant_types` 陣列有放入 `"password"`,則可以使用此 grant_type,必須多傳入 `username` 和 `password` 兩個變數: ```csharp= using System.Net.Http.Headers; using System.Text; using System.Text.Json; string token = await GetToken(); Console.WriteLine(token); Console.ReadKey(); async Task<string> GetToken() { HttpClient client = new HttpClient(); string url = "$host$/SASLogon/oauth/token"; byte[] byteCredential = Encoding.UTF8.GetBytes("app.demo:myPassword"); string stringCredential = Convert.ToBase64String(byteCredential); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", stringCredential); var paras = new List<KeyValuePair<string, string>>(); paras.Add(new KeyValuePair<string, string>("grant_type", "password")); paras.Add(new KeyValuePair<string, string>("username", "XXXXXXXX")); paras.Add(new KeyValuePair<string, string>("password", "OOOOOOOO")); var content = new FormUrlEncodedContent(paras); using (var response = await client.PostAsync(url, content)) { string? jsonResult = await response.Content.ReadAsStringAsync(); JsonDocument doc = JsonDocument.Parse(jsonResult); JsonElement root = doc.RootElement; if (root.TryGetProperty("access_token", out JsonElement access_token)) { return access_token.GetString(); } else { return "*"; } } } ``` 關於 `authorization_code` 與 `refresh_token` 的 token 取法,請參照官方文件 ## 五、使用取到的 token 如同前面用網頁取來的 token,不論是 cURL 還是 Postman 都是用 Bearer 的 Authorization 來做請求,所以將取得的 token 以 Bearer 前綴放在 header。 ```csharp= using System.Net.Http.Headers; using System.Text; using System.Text.Json; string token = await GetToken(); var data = GetData(); await RequestToSas(token, data); Console.ReadKey(); async Task<string> GetToken() { // 同前 } async Task RequestToSas(string token, Data data) { HttpClient client = new HttpClient(); // 決策發行後的模組 URI,注意可能要加 /steps/execute string url = "$host$/$api$"; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); using (var jsonContent = new StringContent( JsonSerializer.Serialize(data), Encoding.UTF8, "application/json")) { using (var response = await client.PostAsync(url, jsonContent)) { string? jsonResult = await response.Content.ReadAsStringAsync(); /* 後續邏輯 */ } } } Data GetData() { /* SAS 決策流程接收變數所需要格式 * { * "inputs":[ * { "name": "EmployeeID_", "value": "54321" }, * { "name": "Salary_", "value": 35000 }, * { "name": "Name_", "value": "陳建國" } * ] * } * */ return new Data(); } ```