jargons.dev の構築 [# 認証システム

王林
リリース: 2024-08-28 06:09:36
オリジナル
404 人が閲覧しました

開発者として、認証は私が最も尊敬しているものの 1 つです。認証を行ってきた私の経験では (おそらく基本レベルで)、特に OAuth を統合する必要がある場合、常に何らかの問題で苦労してきました。

jargons.dev でこれに取り組む前に、私の最近の Auth の経験は、GitHub OAuth を統合した Hearts でした。

そうだね!私はまた、jargons.dev に関しても (伝統的な ?) 苦労をしました。しかし、正直なところ、これはセットアップ (つまりテクノロジー) の違いだけが原因でした。Hearts での私の経験では、GitHub OAuth を NextJS のサーバー アクションと統合していましたが、jargons.dev では、GitHub OAuth と Astro を統合しています。

反復

私が現在書いているように、認証システムは 3 回の反復を経ており、さらに多くの反復が計画されています (次回の反復の詳細については、この号 #30 で説明します)。数週間にわたる開発の反復により、いくつかの未発見の制限により、改善が実装されたり、1 つか 2 つのことがリファクタリングされたりしました。

最初の反復

この反復は、GitHub OAuth フローの開始、ユーザーの Cookie に安全に保存される accessToken の認証コードを交換する応答処理を可能にする基本認証機能に実装されています。

この反復に関して述べる価値のある必須の変更は次のとおりです
  • 私は、きめ細かいトークン提供による権限を使用する GitHub アプリ OAuth を統合しました。これにより、refreshToken を使用した有効期間の短い accessToken が約束されます。
    • 認証関連のリクエストを処理するために 2 つの API ルートを実装しました
    • api/github/oauth/callback - フロー認可コードを使用してリクエストが行われた特定のパスにリダイレクトすることで、OAuth フローからの応答を処理します。
    • api/github/oauth/authorize - リダイレクト パスから呼び出されるルート。フロー認可コードとともに提供され、コードをアクセス トークンと交換し、それを応答として返します。
  • 私は最初のアクションを実装しました (新しく実験的な Astro Server Actions とは関係ありません。発表のかなり前にこれを実行しました?) — これは、サーバー側で実行される関数を呼び出すために私が作った用語です。 Astro は、「ページが読み込まれる前に」、その命名規則 doAction と、astroGlobal オブジェクトを唯一のパラメーターとして受け取るスタイルによってわかります。通常、応答オブジェクトを返す非同期関数です。
    • doAuth - このアクションは保護したいページに統合され、Cookie 内のアクセス トークンの存在をチェックします。 — 存在する場合: それをユーザー データと交換し、保護されたページの認証を確認するためにブール値 isAuthed を一緒に返します。 — トークンが見つからない場合: URL 検索パラメータに oath フロー認可コードが存在することを確認し、それを (api/github/oauth/authorize ルートを呼び出して) アクセス トークンと交換し、それを Cookie に安全に保存してから、クッキーを適切に。 Cookie に accessToken が見つからず、URL 検索パラメータに認証コードがない場合、戻り値 isAuthed は false となり、保護されたページでログイン ページにリダイレクトするために使用されます。
      const { isAuthed, authedData: userData } = await doAuth(Astro);
      if (!isAuthed) return redirect(`/login?return_to=${pathname}`);
      
      ログイン後にコピー
    • ...この doAuth アクションは、GitHub OAuth フロー URL の生成に使用されるユーティリティ関数 getAuthUrl も返します。この関数は、ログイン ページの「GitHub に接続」へのリンクとして追加され、クリックすると、 OAuth フロー

PR を参照:

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

3回目の反復

この反復では、別のスクリプトで行っていた作業中に特定の制限が表面化したため、「最初の反復」の実装の大部分がリファクタリングされます。

この時点では、私は「Submit Word」スクリプトに取り組んでいました。このスクリプトは GitHub API を活用し、現在認証されているユーザーのフォーク ブランチから行われた変更をベース (jargons.dev) メイン ブランチにマージするためのプル リクエストを作成します。これはもちろん、Cookie に保存されたユーザーのアクセス トークンによって可能になります。このアクセス トークンは、SDK (つまり、GitHub API との対話を容易にする Octokit) によって「認証ベアラー トークン」としてリクエスト ヘッダーで使用されます。

限界

テスト中に送信 Word スクリプトを試してみたところ、エラーが発生しました...

エラー: 統合によってリソースにアクセスできません

...これはすぐに障害となるため、@gr2m に相談し、GitHub アプリの統合に関連する制限をすぐに発見しました。

最初に述べたように、GitHub アプリはきめ細かいトークンで「権限」を使用します。これは、GitHub がいくつかの非常に正当な理由で推奨している新しいトークン タイプであり、ここで懸念されるものとして以下に引用したものがあります...

GitHub アプリでは、アプリの実行内容をより詳細に制御できます。 OAuth アプリが使用する広範なスコープの代わりに、GitHub アプリはきめ細かいアクセス許可を使用します。たとえば、アプリがリポジトリのコンテンツを読み取る必要がある場合、OAuth アプリにはリポジトリ スコープが必要です。これにより、アプリはリポジトリのコンテンツと設定を編集することもできます。 GitHub アプリは、リポジトリ コンテンツへの読み取り専用アクセスをリクエストできます。これにより、アプリはリポジトリ コンテンツや設定の編集など、より特権的なアクションを実行できなくなります。

...これは、「権限」(つまり、きめ細かい権限) を使用する場合、ユーザーはアップストリーム/ベース リポジトリ (この場合は jargons.dev リポジトリ) への書き込みアクセス権を持っている必要があることを意味します。 GitHub の「Create a Pull Request」ドキュメントに記載されているとおりです。

何と言うの!?いいえ!!!

その時点で、私たちは単純な古いスコープがまさに私たちが必要としているものであることがわかりました。必要なリソースにアクセスできるようにするには、public_repo スコープがすべてでした。

GitHub アプリから GitHub の OAuth アプリへのスワップ

次に進むには、「アクセス許可」から「スコープ」に切り替える必要がありましたが、それが GitHub の「OAuth アプリ」にあることがわかりました。これは、3 回目のイテレーションにパッチを適用するための基礎でした。

このイテレーションは主に GitHub OAuth 統合の交換に重点を置き、加えようとしていた変更の量を減らすために、このイテレーションで実装されたヘルパー/関数/API が GitHub アプリで利用可能になったものと確実に似ていることを確認しました。新しい実装を承認するために、コードベース全体にわたって。

トレードオフ

GitHub アプリは素晴らしいです。最終的に「エラー: 統合エラーによりリソースにアクセスできません」の解決策を見つけることになった場合の将来のことを考えていることを認めなければなりませんが、プル リクエストを作成する機能は実行されましたsubmit-word スクリプトによる実行はプロジェクトの不可欠な部分であるため、それが機能することを確認する必要があることは間違いありません。

機能を優先するために妥協しなければならないいくつかのトレードオフがあったことを述べておくことが重要です...

  • 有効期限の短いトークンはもう不要 - GitHub アプリは、一定期間後に期限切れになる accessToken と、このトークンを更新するための RefreshToken を提供します。これはセキュリティにとって非常に良いことです。まったく期限切れのない accessToken を提供する OAuth アプリとは異なり、もちろん、refreshToken も提供しません
  • jargons.dev 経由でのみ機能するトークンはもうありません - jargons.dev 経由で開始された OAuth フロー経由で生成された accessToken は、jargons.dev 経由でリクエストを行うためにのみ使用できるため、このトークンを他の場所で認証として使用するために取得します。 GitHub アカウントから生成された通常の個人アクセス トークンを使用する場合と同様に、どこでも取得して使用できる accessToken を提供する OAuth アプリとは異なります。

回避策

  • 有効期限の短いトークンはもう不要 - accessToken を Cookie に保存するときに、少なくとも Cookie から削除されるようにするために、accessToken に意図的に 8 時間の有効期限を追加しました。そのため、新しい OAuth フローをトリガーして新しい accessToken を確保します (本当にそうなっている場合)。の場合) はフローから生成されます。
  • jargons.dev 経由でのみ機能する No More トークン - あはは、accessToken を Cookie に保存する時点で暗号化されることを必ず明記しなければなりません。つまり、暗号化されたトークンが役立つ可能性は低いということです。なぜなら、彼らは私たちが暗号化したものを復号化する必要があるからです。したがって、jargons.dev のみがロックを解除できるトークンにロックを設定したと言えます。

PR を参照:

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 中国語 Web サイトの他の関連記事を参照してください。

ソース:dev.to
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート