首頁 > web前端 > js教程 > 主體

建置 jargons.dev [# 身份驗證系統

王林
發布: 2024-08-28 06:09:36
原創
395 人瀏覽過

作為一名開發人員,身份驗證是我最尊重的事情之一;根據我進行身份驗證(也許是基礎級別)的經驗,我總是為一件事或另一件事而苦苦掙扎,尤其是當我必須整合OAuth 時。

在為 jargons.dev 進行此工作之前,我最近一次進行 Auth 的經驗是在 Hearts 上整合了 GitHub OAuth。

所以是的!我在為jargons.dev 做這件事時也遇到了(傳統的?)困難;但老實說,這只是因為設置(即技術)方面的差異- 我在Hearts 上的經驗是將GitHub OAuth 與NextJS 中的伺服器操作集成,同時在jargons.dev 上,我正在將GitHub OAuth 與Astro 集成。

迭代

正如我目前所寫,身份驗證系統已經經歷了3 次迭代,併計劃進行更多迭代(下一次迭代的詳細信息請參見本期#30);由於一些未發現的限制,這些幾週的開發迭代已經實現了改進或重構了一兩件事。

第一次迭代

此迭代在基本身份驗證功能中實現,允許啟動 GitHub OAuth 流程,響應處理將身份驗證代碼交換為我們安全存儲在用戶 cookie 上的 accessToken。

這次迭代中值得一提的重要變化是
  • 我整合了一個 GitHub 應用程式 OAuth,它使用權限及其細粒度的令牌產品;這承諾了一個帶有刷新令牌的短期 accessToken。
    • 我實作了 2 個 API 路由來處理 Auth 相關請求
    • api/github/oauth/callback - 透過使用流授權程式碼重新導向到發出請求的特定路徑來處理來自 OAuth 流的回應
    • api/github/oauth/authorize-從重定向路徑呼叫的路由,與流授權程式碼一起提供,交換存取權杖的程式碼並將其作為回應傳回。
  • 我實現了第一個操作(與新的實驗性Astro 伺服器操作無關,我在宣布之前很久就完成了這個?) - 這是我剛剛編造的一個術語,用於調用在伺服器端運行的函數Astro“在頁面載入之前”,你應該知道它的命名約定:doAction,以及它以astroGlobal 物件作為唯一參數的風格,通常是傳回回應物件的非同步函數。
    • doAuth - 此操作集成在我希望保護的任何頁面上,它檢查cookie 中是否存在訪問令牌; — 如果存在:它將其交換為用戶數據,並返回一個布林值isAuthed 以確認受保護頁面的身份驗證; — 如果未找到令牌:它會檢查url 搜尋參數中是否存在oath 流授權程式碼,將其交換為存取權杖(透過呼叫api/github/oauth/authorize 路由)並將其安全性儲存到cookie,然後使用適當地設定cookie;現在,如果cookie 中沒有找到accessToken 並且url 搜尋參數中沒有授權碼,則傳回值isAuthed 為false,並將在受保護頁面上使用它來重定向到登入頁面。
      const { isAuthed, authedData: userData } = await doAuth(Astro);
      if (!isAuthed) return redirect(`/login?return_to=${pathname}`);
      
      登入後複製
    • ...這個doAuth 操作還傳回一個實用函數getAuthUrl,用於產生GitHub OAuth 流url,該url 又作為連結新增至登入頁面上的“Connect with GitHub”,一旦點擊,它就會啟動一個OAuth 流程

查看公關:

Building jargons.dev [# The Authentication System

壯舉:實現身份驗證(使用 github oauth) #8
Building jargons.dev [# The Authentication System
發佈於
2024 年 3 月 29 日
<script> // Detect dark theme var iframe = document.getElementById('tweet-1735788008085856746-446'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1735788008085856746&theme=dark" } </script> <script> // Detect dark theme var iframe = document.getElementById('tweet-1773811079145009311-808'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=1773811079145009311&theme=dark" } </script>

This Pull request implement the authentication feature in the project; using the github oauth, our primary goal is to get and hold users github accessToken in cookies for performing specific functionality. It is important to state that this feature does not take store this user's accessToken to any remote server, this token and any other information that was retrieved using the token are all saved securely on the users' end through usage of cookies.

Changes Made

  • Implemented the github oauth callback handler at /api/github/oauth/callback - this handler's main functionality is to receive github's authorization code and state to perform either of the following operations

    • Redirect to the path stated in the state params with the authorization code concatenated to it using the Astro.context.redirect method
    • or If a redirect=true value if found in the state param, then we redirect to the the path stated in the state params with the authorization code and redirect=true value concatenated to it using Astro.context.redirect method
  • Implemented the github oauth authorization handler at /api/github/oauth/authorization - this handler is a helper that primarily exchanges the authorization code for tokens and returns it in a json object.

  • Created a singleton instance of our github app at lib/octokit/app

  • Added a new crypto util function which provides encrypt and decrypt helper function has exports; it is intended to be used for securing the users related cookies

  • Implemented the doAuth action function - this function take the Astro global object as argument and performs the operations stated below

    /**
     * Authentication action with GitHub OAuth
     * @param {import("astro").AstroGlobal} astroGlobal 
     */
    export default async function doAuth(astroGlobal) {
      const { url: { searchParams }, cookies } = astroGlobal;
      const code = searchParams.get("code");
      const accessToken = cookies.get("jargons.dev:token", {
        decode: value => decrypt(value)
      });
    
      /**
       * Generate OAuth Url to start authorization flow
       * @todo make the `parsedState` data more predictable (order by path, redirect)
       * @todo improvement: store `state` in cookie for later retrieval in `github/oauth/callback` handler for cleaner url
       * @param {{ path?: string, redirect?: boolean }} state 
       */
      function getAuthUrl(state) {
        const parsedState = String(Object.keys(state).map(key => key + ":" + state[key]).join("|"));
        const { url } = app.oauth.getWebFlowAuthorizationUrl({
          state: parsedState
        });
        return url;
      }
    
      try {
        if (!accessToken && code) {
          const response = await GET(astroGlobal);
          const responseData = await response.json();
      
          if (responseData.accessToken && responseData.refreshToken) {
            cookies.set("jargons.dev:token", responseData.accessToken, {
              expires: resolveCookieExpiryDate(responseData.expiresIn),
              encode: value => encrypt(value)
            });
            cookies.set("jargons.dev:refresh-token", responseData.refreshToken, {
              expires: resolveCookieExpiryDate(responseData.refreshTokenExpiresIn),
              encode: value => encrypt(value)
            });
          }
        }
      
        const userOctokit = await app.oauth.getUserOctokit({ token: accessToken.value });
        const { data } = await userOctokit.request("GET /user");
      
        return {
          getAuthUrl,
          isAuthed: true,
          authedData: data
        }
      } catch (error) {
        return {
          getAuthUrl,
          isAuthed: false,
          authedData: null
        }
      }
    }
    登入後複製
    Enter fullscreen mode Exit fullscreen mode
    • it provides (in its returned object) a helper function that can be used to generate a new github oauth url, this helper consumes our github app instance and it accepts a state object with path and redirectproperty to build out thestate` value that is held within the oauth url
    • it sets cookies data for tokens - it does this when it detects the presence of the authorization code in the Astro.url.searchParams and reads the absense no project related accessToken in cookie; this assumes that there's a new oauth flow going through it; It performs this operation by first calling the github oauth authorization handler at /api/github/oauth/authorization where it gets the tokens data that it adds to cookie and ensure its securely store by running the encrypt helper to encode it value
    • In cases where there's no authorization code in the Astro.url.searchParams and finds a project related token in cookie, It fetches users's data and provides it in its returned object for consumptions; it does this by getting the users octokit instance from our github app instance using the getUserOctokit method and the user's neccesasry tokens present in cookie; this users octokit instance is then used to request for user's data which is in turn returned
    • It also returns a boolean isAuthed property that can be used to determine whether a user is authenticated; this property is a statically computed property that only always returns turn when all operation reaches final execution point in the try block of the doAuth action function and it returns false when an error occurs anywhere in the operation to trigger the catch block of the doAuth action function
  • Added the login page which stands as place where where unauthorised users witll be redirected to; this page integrates the doAuth action, destruing out the getAuthUrl helper and the isAuthed property, it uses them as follows

    const { getAuthUrl, isAuthed } = await doAuth(Astro);
    
    if (isAuthed) return redirect(searchParams.get("redirect"));
    
    const authUrl = getAuthUrl({
      path: searchParams.get("redirect"),
      redirect: true
    });
    登入後複製
    Enter fullscreen mode Exit fullscreen mode
    • isAuthed - this property is check on the server-side on the page to check if a user is already authenticated from within the doAuth and redirects to the value stated the page's Astro.url.searchParams.get("redirect")
    • When a user is not authenticated, it uses the getAuthUrl to generate a new github oauth url and imperatively set the argument state.redirect to true
    • Implemented a new user store with a Map store value $userData to store user data to state

Integration Demo: Protect /sandbox page

// pages/sandbox.astro

---
import BaseLayout from "../layouts/base.astro";
import doAuth from "../lib/actions/do-auth.js";
import { $userData } from "../stores/user.js";

const { url: { pathname }, redirect } = Astro;
const { isAuthed, authedData } = await doAuth(Astro);

if (!isAuthed) return redirect(`/login?redirect=<span class="pl-s1"><span class="pl-kos">${pathname}</span>`</span>);

$userData.set(authedData);
---

<BaseLayout pageTitle="Dictionary">
  <main class="flex flex-col max-w-screen-lg p-5 justify-center mx-auto min-h-screen">
    <div class="w-fit p-4 ring-2 rounded-full ring-gray-500 m-auto flex items-center space-x-3">
      <img class="w-10 h-10 p-1 rounded-full ring-2 ring-gray-500" 
        src={authedData.avatar_url} 
        alt={authedData.login}
      >
      <p>Hello, { authedData.login }</p>      
    </div>
  </main>
</BaseLayout>
登入後複製
Enter fullscreen mode Exit fullscreen mode

Explainer

  • We destructure isAuthed and authedData from the doAuth action
  • Check whether a user is not authenticated? and do a redirect to login page stating the current pathname as value for the redirect search param (a data used in state to dictate where to redirect to after authentication complete) if no user is authenticated
  • or Proceed to consuming the authedData which will be available when a isAuthed is true. by setting it to the $userData map store property

Screencast/Screenshot

screencast-bpconcjcammlapcogcnnelfmaeghhagj-2024.03.29-20_36_15.webm

Note

  • Added new node package https://www.npmjs.com/package/@astrojs/node for SSR adapter intergation
View on GitHub

Second Iteration

This iteration implements improvements by making making the parsedState derived from the getAuthUrl function call more predictable removing the chances of an error in the api/github/oauth/callback route; it also renames some terms used in the search params and implements the the encodeURIComponent to make our redirect urls look less weird

See PR:

Building jargons.dev [# The Authentication System feat: implement `auth` (second iteration) improvements #28

Building jargons.dev [# The Authentication System
babblebey posted on

This PR implements some improvement to mark the second iteration of the auth feature in the project. Follow-up to #8

Changes Made

  • Addressed "todo make the parsedState data more predictable (order by path, redirect)" by implementing more predicatable manner of generating the state string for the oauth url; this is done by individually looking for the required state object key to fill in the parsedState string in required order. Leaving the new getAuthUrl helper function looking like so...
function getAuthUrl(state) {
  let parsedState = "";

  if (!isObjectEmpty(state)){
    if (state.path) parsedState += `path:<span class="pl-s1"><span class="pl-kos">${state.path}</span>`</span>;
    const otherStates = String(Object.keys(state)
      .filter(key => key !== "path" && key !== "redirect")
      .map(key => key + ":" + state[key]).join("|"));
    if (otherStates.length > 0) parsedState += `|<span class="pl-s1"><span class="pl-kos">${otherStates}</span>`</span>;
  }

  const { url } = app.oauth.getWebFlowAuthorizationUrl({
    state: parsedState
  });

  return url;
}
登入後複製
Enter fullscreen mode Exit fullscreen mode
  • Implemented a new utility function isObjectEmpty to check if an object has value or not
  • Removed usage of redirect property in state object; its redundant ?‍?
  • Renamed login redirect path params property name to return_to from redirect for readability reasons
  • Implemented encodeURIComponent in login redirect path params value to stop its part on the url from looking like weird ?;
    • Takes the example url from looking like this... /login?return_to=/editor to looking like so... /login?return_to=%2Feditor

Related Isssue

Resolves #15

View on GitHub

第三次迭代

由於我在另一個腳本上所做的工作期間出現了某種限制,因此這次迭代重構了「第一次迭代」中實現的大部分部分。

此時我正在編寫“Submit Word”腳本;此腳本利用GitHub API 並創建拉取請求,以將從當前經過身份驗證的用戶的fork 分支所做的更改合併到基礎(jargons.dev) 主分支。當然,這是透過儲存到 cookie 中的使用者存取權令牌來實現的,該令牌在請求標頭中由 SDK(即 Octokit)用作“授權承載令牌”,方便我們與 GitHub API 進行互動。

限制

在測試期間,當我嘗試提交單字腳本時,我遇到了錯誤......

錯誤:整合無法存取資源

...這很快就成為了一個障礙,我諮詢了@gr2m,我們很快就發現了與我的 GitHub 應用程式整合相關的限制。

正如最初所述,GitHub 應用程式使用帶有細粒度令牌的「權限」 - GitHub 出於一些非常好的原因而鼓勵使用的新令牌類型,下面引用的一個是我們在這裡關注的....

GitHub 應用程式提供了對應用程式功能的更多控制。 GitHub 應用程式使用細粒度的權限,而不是 OAuth 應用程式使用的廣泛範圍。例如,如果您的應用程式需要讀取儲存庫的內容,則 OAuth 應用程式將需要儲存庫範圍,這也允許應用程式編輯儲存庫內容和設定。 GitHub 應用程式可以請求對儲存庫內容的唯讀存取權限,這不會讓應用程式執行更多特權操作,例如編輯儲存庫內容或設定。

...這表示當使用「權限」(即細粒度權限)時,使用者必須具有對上游/基礎儲存庫的寫入權限,在本例中是我們的jargons.dev 儲存庫;如GitHub 建立拉取請求文件中所述。

說什麼! ?沒有! ! !

就在那時,我們發現普通的舊作用域正是我們所需要的;為了能夠存取所需的資源,public_repo 範圍就是一切。

從 GitHub App 交換 GitHub 的 OAuth 應用程式

為了繼續前進,我必須從“權限”切換到“範圍”,我們在 GitHub 的“OAuth App”中發現了這一點;這是第三次迭代修補的基礎。

因此,本次迭代主要側重於交換GitHub OAuth 集成,同時確保本次迭代中實現的幫助程序/函數​​/api 類似於GitHub 應用程式提供的幫助程序/函數​​/api,以減少我要做的更改量跨越整個程式碼庫以感謝新的實現。

權衡

GitHub 應用程式很棒,我必須承認,如果我們最終找到錯誤的解決方案:資源無法通過集成錯誤訪問,但創建拉取請求的功能已執行,我仍然會考慮到未來提交單詞腳本是該專案的重要組成部分,因此我們必須確保它有效。

需要指出的是,為了支援功能,我必須做出一些權衡......

  • 不再有短期令牌- GitHub App 提供了一個在一定時間後過期的accessToken 和一個刷新此令牌的刷新令牌;這對於安全來說非常有好處;與OAuth App 不同的是,OAuth App 提供的accessToken 永遠不會過期,當然也不提供刷新令牌
  • 不再有僅透過jargons.dev 運作的令牌- 我理解(聲明很重要)透過jargons.dev 啟動的OAuth 流程產生的accessToken 只能用於透過jargons.dev 發出請求,因此不可能將此令牌用作其他地方的授權;與OAuth 應用程式不同的是,OAuth 應用程式提供了可以在任何其他地方取得和使用的accessToken,就像您使用從GitHub 帳戶產生的普通個人存取權杖一樣。

解決方法

  • 不再有短期令牌- 在將accessToken 保存到cookie 時,我特意為它添加了8 小時的有效期,以確保它至少從cookie 中刪除,從而觸發新的OAuth 流程以確保新的accessToken(如果確實如此)是這樣)是從流中產生的。
  • 不再有僅透過jargons.dev 運作的令牌- 哈哈,必須說明的是,當我們將accessToken 儲存到cookie 時,它會被加密,這意味著加密的令牌不太可能有用其他任何地方,因為他們需要解密我們已加密的內容。所以你可以說我們已經在令牌上加了一把鎖,只有 jargons.dev 才能解鎖。

查看公關:

Building jargons.dev [# The Authentication System refactor(auth): replace `github-app-oauth` with classic `oauth` app #33

Building jargons.dev [# The Authentication System
babblebey posted on

This Pull request refactors the authentication system, replacing the usage of github-app-oauth with classic github oauth app. This decision was taken because of the limitations discovered using the Pull Request endpoint (implementation in #25); the github-app-oauth uses permissions which requires a user to have write access to the upstream (i.e. write access to atleast pull-requests on our/this project repo) before a pull request can created from their forked repo branch to the main project repo.

This PR goes to implement classis oauth app, which uses scopes and allows user access to create the pull request to upstream repo on the public_repo scope. The changes made in this PR was done to mimic the normal Octokit.App's methods/apis as close as possible to allow compatibility with the implementation in #8 and #28 (or for cases when we revert back to using the github-app-oauth in the future --- maybe we end up finding a solution because honestly I really prefer the github-app-oauth ?).

It is also important to state that this oauth app option doesn't offer a short lived token (hence we only have an accessToken without expiry and No refreshToken), but I have configured the token to expire out of cookie in 8hours; even though we might be getting exactly thesame token back from github after this expires and we re-authorize the flow, I just kinda like that feeling of the cookies expiring after some hours and asking user to re-auth.

Changes Made

  • Initialized a new app object that returns few methods and objects
    • octokit - the main octokit instance of the oauth app

      /**
       * OAuth App's Octokit instance
       */
      const octokit = new Octokit({
        authStrategy: createOAuthAppAuth,
        auth: {
          clientId: import.meta.env.GITHUB_OAUTH_APP_CLIENT_ID,
          clientSecret: import.meta.env.GITHUB_OAUTH_APP_CLIENT_SECRET
        },
      });
      登入後複製
      Enter fullscreen mode Exit fullscreen mode
    • oauth

      • getWebFlowAuthorizationUrl - method that generates the oauth flow url

        /**
         * Generate a Web Flow/OAuth authorization url to start an OAuth flow
         * @param {import("@octokit/oauth-authorization-url").OAuthAppOptions} options
         * @returns 
         */
        function getWebFlowAuthorizationUrl({state, scopes = ["public_repo"], ...options }) {
          return oauthAuthorizationUrl({
            clientId: import.meta.env.GITHUB_OAUTH_APP_CLIENT_ID,
            state,
            scopes,
            ...options
          });
        }
        登入後複製
        Enter fullscreen mode Exit fullscreen mode
      • exchangeWebFlowCode - method that exchanges oauth web flow returned code for accessToken; this functionality was extracted from the github/oauth/authorize endpoint to have all auth related function packed in one place

        /**
         * Exchange Web Flow Authorization `code` for an `access_token` 
         * @param {string} code 
         * @returns {Promise<{access_token: string, scope: string, token_type: string}>}
         */
        async function exchangeWebFlowCode(code) {
          const queryParams = new URLSearchParams();
          queryParams.append("code", code);
          queryParams.append("client_id", import.meta.env.GITHUB_OAUTH_APP_CLIENT_ID);
          queryParams.append("client_secret", import.meta.env.GITHUB_OAUTH_APP_CLIENT_SECRET);
        
          const response = await fetch("https://github.com/login/oauth/access_token", {
              method: "POST",
              body: queryParams
            });
          const responseText = await response.text();
          const responseData = new URLSearchParams(responseText);
        
          return responseData;
        }
        登入後複製
        Enter fullscreen mode Exit fullscreen mode
    • getUserOctokit - method that gets an octokit instance of a user.

      /**
       * Get a User's Octokit instance
       * @param {Omit<OctokitOptions, "auth"> & { token: string }} options
       * @returns {Octokit}
       */
      function getUserOctokit({ token, ...options }) {
        return new Octokit({
          auth: token,
          ...options
        });
      };
      登入後複製
      Enter fullscreen mode Exit fullscreen mode
  • Integrated the app.oauth.exchangeWebFlowCode method into the github/oauth/authorize endpoint handler
  • Removed the refreshToken and refreshTokenExpiresIn from github/oauth/authorize endpoint response object.
  • Modified doAuth actions
    • Removed jargons.dev:refresh_token value set to cookie;
    • Corrected computation of userOctokit to use app.getUserOctokit from app.oauth.getUserOctokit (even though I could just move the getUserOctokit method to the app.oauth object in the new implmentation, I just prefer it this way ?).

?

Screencast

screencast-bpconcjcammlapcogcnnelfmaeghhagj-2024.04.07-07_37_31.webm

View on GitHub

以上是建置 jargons.dev [# 身份驗證系統的詳細內容。更多資訊請關注PHP中文網其他相關文章!

來源:dev.to
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!