首页  >  文章  >  web前端  >  构建 jargons.dev [# 身份验证系统

构建 jargons.dev [# 身份验证系统

王林
王林原创
2024-08-28 06:09:36397浏览

作为一名开发人员,身份验证是我最尊重的事情之一;根据我进行身份验证(也许是基础级别)的经验,我总是为一件事或另一件事而苦苦挣扎,尤其是当我必须集成 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);
---

11cef86d50b82c68655376cb62f0db5b
  e33cc9d786368fb494b726688270e59a
    713e9867a88cfb8c49f3f463c33c78a3
      3f1d768d1c2bece0763d63bdace7515a
      e388a4556c0f65e1904146cc1a846beeHello, { authedData.login }94b3e26ee717c64999d7867364b1b4a3      
    16b28748ea4df4d9c2150843fecfba68
  07ebbc2b3735b6cb0330715d08d2749e
a7404faa8989ab176c7e812b70808e66
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 应用程序提供的类似,以减少我要做的更改量跨越整个代码库以感谢新的实现。

权衡

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 {Promise4ae3230490577696c575abb709ab983c}
         */
        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 {Omit1e4fb368bde7d280f14ef2719604d1ea & { 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中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn