Maison  >  Article  >  interface Web  >  Construire jargons.dev [# Le système d'authentification

Construire jargons.dev [# Le système d'authentification

王林
王林original
2024-08-28 06:09:36316parcourir

En tant que développeur, l'authentification est l'une des choses pour laquelle j'ai le plus de respect ; D'après mon expérience en matière d'authentification (peut-être à un niveau basique), j'ai toujours eu du mal avec une chose ou une autre, surtout lorsque je dois intégrer OAuth.

Avant de travailler là-dessus pour jargons.dev, ma plus récente expérience en matière d'authentification était sur Hearts où j'ai intégré GitHub OAuth.

Alors oui ! J'ai également eu mes difficultés (traditionnelles ?) à travailler là-dessus pour jargons.dev également ; mais honnêtement, c'était uniquement à cause des différences de configuration (c'est-à-dire de technologie) - Mon expérience sur Hearts consistait à intégrer GitHub OAuth avec Server Actions dans NextJS, tandis que sur jargons.dev, j'intègre GitHub OAuth avec Astro.

Les itérations

Au moment où j'écris actuellement, le système d'authentification a traversé 3 itérations, et d'autres sont prévues (détails sur la prochaine itération dans ce numéro #30) ; ces itérations au fil des semaines de développement ont mis en œuvre des améliorations ou refactorisé une ou deux choses en raison d'une limitation découverte.

Première itération

Cette itération implémentée dans la fonctionnalité d'authentification de base qui permet l'initiation d'un flux GitHub OAuth, une gestion des réponses qui échange le code d'authentification contre un accessToken que nous stockons en toute sécurité sur les cookies de l'utilisateur.

Les changements impératifs qui méritent d'être signalés à propos de cette itération sont les suivants
  • J'ai intégré une application GitHub OAuth qui utilise les autorisations avec son offre de tokens à granularité fine ; cela promet un accessToken de courte durée avec un rafraîchissementToken.
    • J'ai implémenté 2 routes API pour gérer les requêtes liées à l'authentification
    • api/github/oauth/callback - qui gère la réponse du flux OAuth en redirigeant vers un chemin spécifique à partir duquel la demande a été faite avec le code d'autorisation du flux
    • api/github/oauth/authorize- une route appelée depuis le chemin de redirection, servie avec le code d'autorisation de flux, échange le code contre un jeton d'accès et le renvoie en réponse.
  • J'ai implémenté la première action (sans rapport avec les nouvelles actions expérimentales Astro Server, je l'ai fait bien avant l'annonce ?) — c'est un terme que je viens d'inventer pour appeler les fonctions qui sont exécutées côté serveur de Astro "avant le chargement de la page", vous le connaîtrez par sa convention de dénomination : doAction, et son style de prise de l'objet astroGlobal comme seul paramètre, c'est généralement une fonction asynchrone qui renvoie un objet de réponse.
    • doAuth - cette action s'intègre sur n'importe quelle page que je souhaite protéger, elle vérifie la présence d'un jeton d'accès dans le cookie ; — s'il est présent : il échange cela contre des données utilisateur, renvoie une valeur booléenne isAuthed à côté pour confirmer l'authentification de la page protégée ; — si aucun jeton n'est trouvé : il vérifie la présence du code d'autorisation du flux Oath dans les paramètres de recherche d'URL, l'échange contre un jeton d'accès (en appelant la route api/github/oauth/authorize) et l'enregistre en toute sécurité dans les cookies, puis utilise le cookie de manière appropriée ; désormais, dans les cas où aucun accessToken n'est trouvé dans les cookies et aucun code d'authentification n'est dans les paramètres de recherche d'URL, alors la valeur renvoyée isAuthed est fausse et elle sera utilisée sur la page protégée pour rediriger vers la page de connexion.
      const { isAuthed, authedData: userData } = await doAuth(Astro);
      if (!isAuthed) return redirect(`/login?return_to=${pathname}`);
      
    • ...cette action doAuth renvoie également une fonction utilitaire getAuthUrl qui est utilisée pour générer une URL de flux GitHub OAuth qui est à son tour ajoutée en tant que lien vers "Se connecter avec GitHub" sur la page de connexion et une fois cliqué, elle démarre un Flux OAuth

Voir PR :

Building jargons.dev [# The Authentication System

exploit : implémenter l'authentification (avec github oauth) #8
Building jargons.dev [# The Authentication System
publié le
29 mars 2024
<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

Troisième itération

Cette itération refactorise la plupart des parties de l'implémentation dans la "Première Itération" en raison d'une certaine limitation qui est apparue lors du travail que je faisais sur un autre script.

À ce moment-là, je travaillais sur le script « Soumettre un mot » ; ce script exploite l'API GitHub et crée une Pull Request pour fusionner les modifications apportées à partir de la branche fork de l'utilisateur actuellement authentifié vers la branche principale de base (jargons.dev). Ceci est bien sûr rendu possible par le jeton d'accès de l'utilisateur enregistré dans les cookies qui est utilisé dans les en-têtes de requête comme « jeton de porteur d'autorisation » par le SDK, c'est-à-dire Octokit qui facilite notre interaction avec les API GitHub.

La limite

Lors d'un moment de test alors que je faisais tourner le script de soumission de mots, j'ai été confronté à l'erreur...

Erreur : Ressource non accessible par intégration

...cela est vite devenu un bloqueur et j'ai consulté @gr2m avec qui nous avons rapidement découvert la limitation qui concernait mes intégrations de GitHub App.

Comme indiqué initialement, l'application GitHub utilise des « Autorisations » avec un jeton à granularité fine - un nouveau type de jeton que GitHub encourage pour de très bonnes raisons, celle citée ci-dessous étant celle qui nous concerne ici...

Les applications GitHub offrent plus de contrôle sur ce que l'application peut faire. Au lieu des vastes portées utilisées par les applications OAuth, les applications GitHub utilisent des autorisations plus précises. Par exemple, si votre application doit lire le contenu d'un référentiel, une application OAuth nécessitera la portée du référentiel, ce qui permettra également à l'application de modifier le contenu et les paramètres du référentiel. Une application GitHub peut demander un accès en lecture seule au contenu du référentiel, ce qui ne permettra pas à l'application d'effectuer des actions plus privilégiées telles que la modification du contenu ou des paramètres du référentiel.

...cela signifie que lors de l'utilisation des "Autorisations" (c'est-à-dire des autorisations précises), un utilisateur doit avoir un accès en écriture au référentiel amont/de base qui dans ce cas est notre référentiel jargons.dev ; comme indiqué dans la documentation GitHub Créer une demande de tirage.

Dis quoi !? Non!!!

C'est à ce moment-là que nous avons découvert que la portée classique était exactement ce dont nous avions besoin ; Afin de pouvoir accéder à la ressource requise, la portée public_repo était essentielle.

L'échange pour l'application OAuth de GitHub depuis l'application GitHub

Pour avancer, j'ai dû passer des « autorisations » à « portée » et nous avons trouvé que cela se trouvait dans « l'application OAuth » de GitHub ; c'est la base sur laquelle la troisième itération a été corrigée.

Cette itération s'est donc principalement concentrée sur l'échange de l'intégration GitHub OAuth, en garantissant également que les assistants/fonctions/API implémentés dans cette itération ressemblent à ceux qui ont été mis à disposition par l'application GitHub pour réduire la quantité de modifications que j'allais apporter. dans l'ensemble de la base de code en reconnaissance de la nouvelle implémentation.

Les compromis

L'application GitHub est géniale, je dois reconnaître que j'ai encore cela en tête pour l'avenir si nous finissons par trouver une solution à l'erreur : ressource non accessible par erreur d'intégration, mais la fonctionnalité pour créer une Pull Request effectuée par le script submit-word est une partie impérative du projet, donc vous pariez que nous devons nous assurer qu'il fonctionne.

Il est important de préciser qu'il y a eu certains compromis que j'ai dû faire en faveur de la fonctionnalité...

  • Plus de jeton de courte durée - l'application GitHub fournit un accessToken qui expire après une certaine période et un rafraîchissement pour actualiser ce jeton ; c'est très bon pour la sécurité ; Contrairement à l'application OAuth qui fournit un accessToken qui n'expire jamais et ne fournit bien sûr pas de rafraîchissement
  • Plus de jeton qui fonctionne uniquement via jargons.dev - J'ai compris (important de préciser) que le accessToken généré via le flux OAuth initié via jargons.dev ne peut être utilisé que pour effectuer une requête via jargons.dev, ce qui rend impossible prenez ce jeton pour l'utiliser comme autorisation ailleurs ; Contrairement à l'application OAuth qui fournit un jeton d'accès qui peut être récupéré et utilisé n'importe où, comme vous utiliseriez un jeton d'accès personnel normal généré à partir de vos comptes GitHub.

Les solutions de contournement

  • Plus de jetons de courte durée - J'ai intentionnellement ajouté une expiration de 8 heures pour le accessToken lors de son enregistrement dans un cookie afin de garantir qu'il soit au moins supprimé du cookie, déclenchant ainsi un nouveau flux OAuth pour garantir un nouveau accessToken (si c'est vraiment le cas). c'est le cas) est généré à partir du flux.
  • Plus de jeton qui ne fonctionne que via jargons.dev - Haha, il est impératif de préciser qu'au moment où nous enregistrons le jeton d'accès dans le cookie, il est crypté, ce qui signifie qu'il est moins probable que le jeton crypté soit utile ailleurs parce qu'ils auraient besoin de décrypter quelque chose que nous avons chiffré. Vous pouvez donc dire que nous avons placé un verrou sur le jeton que seul jargons.dev peut déverrouiller.

Voir 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 {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

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn