😎 React App 通过开源 SSO Auth Wizardry 升级 🪄

2025-06-07

😎 React App 通过开源 SSO Auth Wizardry 升级 🪄

TL;DR

我是 Nathan,我喜欢的事情之一是 React 开发。

在本教程结束时,您将拥有一个合适的 React 应用程序,该应用程序将通过 SAML 单点登录对用户进行身份验证。

在深入讨论实施细节之前,请确保您已做好以下准备:

尤达宝宝

目录

为什么你应该关心 SSO

SAML SSO 支持无缝、安全的身份验证,减少了用户记住多个密码的需要,并最大限度地降低了与密码相关的安全漏洞的风险。

它还允许企业集中管理用户访问和权限,从而更容易控制谁有权访问应用程序以及他们可以在其中做什么。

通过集成 SAML SSO,您的应用程序符合身份验证和访问控制的行业标准,提供值得信赖且高效的用户体验,同时增强应用程序的安全性和管理功能。

现在我们已经满足了先决条件,现在可以开始为用户启用SAML单点登录身份验证了。🎉


我提到过 SAML...你可能听说过他?❤️

SAML杰克逊

请为 SAML Jackson 代码库加注 ⭐


集成步骤概述

将 SAML 单点登录集成到您的 React 应用程序中涉及以下关键步骤:

  • 配置 SAML 单点登录:为您的应用程序设置 SAML 单点登录。

  • 使用 SAML 单点登录进行身份验证:使用 SAML 单点登录实现身份验证过程。

  • 在 React 上配置企业 SSO:允许您的租户为其用户配置 SAML 连接。

现在,让我们深入了解每个步骤的细节。

使用 SAML 单点登录进行身份验证

将 SAML 连接添加到应用程序后,即可使用它通过 SAML Jackson 启动 SSO 身份验证流程。本节将重点介绍 SSO 身份验证过程。

部署 SAML Jackson

首先,部署 SAML Jackson 服务。请按照部署文档安装并配置 SAML Jackson。

设置 SAML Jackson 集成

我们将使用客户端库@bity/oauth2-auth-code-pkce来实现身份验证过程。

此库是一个零依赖的 OAuth 2.0 客户端,它使用 PKCE 实现授权码授予,以提供客户端保护。您可以使用 npm 安装它:



npm i --save @bity/oauth2-auth-code-pkce


Enter fullscreen mode Exit fullscreen mode

接下来,配置OAuth2AuthCodePKCE客户端以使用 SAML Jackson 服务进行身份验证。您可以创建一个自定义钩子,以oauthclient在整个应用中使用它。

让我们首先创建这个文件:



src/hooks/useOAuthClient.ts

import { OAuth2AuthCodePKCE } from '@bity/oauth2-auth-code-pkce';
import { useEffect, useState } from 'react';

const JACKSON_URL = process.env.REACT_APP_JACKSON_URL;

interface OauthClientOptions {
  redirectUrl: string;
}
export default function useOAuthClient({
  redirectUrl,
}: OauthClientOptions): OAuth2AuthCodePKCE | null {
  const [oauthClient, setOauthClient] = useState<OAuth2AuthCodePKCE | null>(
    null
  );

  useEffect(() => {
    setOauthClient(
      new OAuth2AuthCodePKCE({
        authorizationUrl: `${JACKSON_URL}/api/oauth/authorize`,
        tokenUrl: `${JACKSON_URL}/api/oauth/token`,
        // Setting the clientId dummy here. We pass additional query params for
        // tenant and product in the authorize request.
        clientId: 'dummy',
        redirectUrl,
        scopes: [],
        onAccessTokenExpiry(refreshAccessToken) {
          console.log('Expired! Access token needs to be renewed.');
          alert(
            'We will try to get a new access token via grant code or refresh token.'
          );
          return refreshAccessToken();
        },
        onInvalidGrant(refreshAuthCodeOrRefreshToken) {
          console.log(
            'Expired! Auth code or refresh token needs to be renewed.'
          );
          alert('Redirecting to auth server to obtain a new auth grant code.');
          //return refreshAuthCodeOrRefreshToken();
        },
      })
    );
  }, [redirectUrl]);

  return oauthClient;
}


Enter fullscreen mode Exit fullscreen mode

设置全局身份验证原语

让我描述一下我们将如何使用AuthContext

为了使身份验证过程在我们的整个应用程序中全局可访问,我们将创建一个AuthContext用于存储有关登录user以及signInsignOut方法的信息。

这些,连同setTenant(用于选择 SSO 流的租户的方法)和authStatus(布尔值,帮助我们根据身份验证状态是否完全已知或正在加载有条件地呈现内容)。

让我们创建下一个文件:



src/lib/AuthProvider.tsx

import React, { useState, useEffect, ReactNode, createContext } from 'react';
import { useLocation } from 'react-router-dom';
import useOAuthClient from '../hooks/useOAuthClient';
import { authenticate, getProfileByJWT } from './backend';

interface ProviderProps {
  children: ReactNode;
}

interface AuthContextInterface {
  setTenant?: React.Dispatch<React.SetStateAction<string>>;
  authStatus: 'UNKNOWN' | 'FETCHING' | 'LOADED';
  user: any;
  signIn: () => void;
  signOut: (callback: VoidFunction) => void;
}

// localstorage key to store from url
const APP_FROM_URL = 'appFromUrl';

export const AuthContext = createContext<AuthContextInterface>(null!);


Enter fullscreen mode Exit fullscreen mode

下一个任务是创建一个自定义钩子,返回一个句柄AuthContext

创建新文件:



src/hooks/useAuth.ts

import { useContext } from 'react';
import { AuthContext } from '../lib/AuthProvider';

const useAuth = () => {
  return useContext(AuthContext);
};

export default useAuth;


Enter fullscreen mode Exit fullscreen mode

授权提供者

接下来,我们将连接内部的流程AuthProvider

  1. 一旦应用程序外壳被渲染,就会运行 useEffect 来引导流程至authClientfrom useOAuthClient

这里需要处理两种情况。

  • 我们从 SSO 提供商(Jackson)获得安全access_token,通过传入 cookie 来检索登录的用户配置文件。

  • 在 IdP 登录后,浏览器会被重定向回应用程序。重定向中的授权码会被交换为访问令牌,然后该令牌会被传递到应用程序后端以完成登录。



src/lib/AuthProvider.tsx

  const AuthProvider = ({ children }: ProviderProps) => {
    const [user, setUser] = useState<any>(null);
    const [authStatus, setAuthStatus] = useState<AuthContextInterface['authStatus']>('UNKNOWN');

    ...

    const redirectUrl = process.env.REACT_APP_APP_URL + from;

    const authClient = useOAuthClient({ redirectUrl });

    useEffect(() => {
      let didCancel = false;

      const loadUser = async () => {
        if (!authClient) {
          return;
        }
        setAuthStatus('FETCHING');
        if (authClient.isAuthorized()) {
          const { data, error } = await getProfileByJWT();
          if (!didCancel && !error) {
            setUser(data);
            setAuthStatus('LOADED');
          }
        } else {
          try {
            const hasAuthCode = await authClient?.isReturningFromAuthServer();
            if (!hasAuthCode) {
              devLogger('no auth code detected...');
            } else {
              const token = !didCancel
                ? await authClient?.getAccessToken()
                : null;
              token && localStorage.removeItem(APP_FROM_URL);
              // authentication happens at the backend where the above token is used
              // to retrieve user profile
              const profile = await authenticate(token?.token?.value);
              if (!didCancel && profile) {
                setUser(profile);
              }
            }
          } catch (err) {
            console.error(err);
          } finally {
            setAuthStatus('LOADED');
          }
        }
      };

      loadUser();
      return () => {
        didCancel = true;
      };
    }, [authClient]);

    ...

     const value = {
      authStatus,
      user,
    };

    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
  };

  export { AuthContext, AuthProvider };


Enter fullscreen mode Exit fullscreen mode
  1. 当有人尝试访问受保护/私人路线时,他们将redirected进入登录页面。

让我们进一步解释一下。

首先,我们在历史状态中保存他们尝试访问的当前位置。

此逻辑封装在RequireAuth包装器组件中。我们将使用它来保护需要身份验证的路由。



src/components/RequireAuth.tsx

const RequireAuth = ({ children }: { children: JSX.Element }) => {
  let { user, authStatus } = useAuth();
  let location = useLocation();

  if (authStatus !== 'LOADED') {
    return null;
  }

  if (!user) {
    // Redirect them to the /login page, but save the current location they were
    // trying to go to when they were redirected. This allows us to send them
    // along to that page after they login, which is a nicer user experience
    // than dropping them off on the home page.
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
};

export default RequireAuth;


Enter fullscreen mode Exit fullscreen mode

接下来我们将使用from中的状态redirectUrl来构造oAuthClient内部AuthProvider



src/lib/AuthProvider.tsx

let location = useLocation();
let from =
  location.state?.from?.pathname ||
  localStorage.getItem(APP_FROM_URL) ||
  '/profile';

const redirectUrl = process.env.REACT_APP_APP_URL + from;

const authClient = useOAuthClient({ redirectUrl });


Enter fullscreen mode Exit fullscreen mode
  1. signIn并且signOut方法可以如下实现:


src/lib/AuthProvider.tsx

const signIn = async () => {
  // store the 'from' url before redirecting ... we need this to correctly initialize
  // the oauthClient after getting redirected back from SSO Provider.
  localStorage.setItem(APP_FROM_URL, from);
  // Initiate the login flow
  await authClient?.fetchAuthorizationCode({
    tenant,
    product: 'saml-demo.boxyhq.com',
  });
};

const signOut = async (callback: VoidFunction) => {
  authClient?.reset();
  setUser(null);
  callback();
};

const value = {
  signIn,
  signOut,
};

return (
  <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);


Enter fullscreen mode Exit fullscreen mode

身份验证请求

让我们添加一个页面来开始身份验证流程。

该页面通过将用户重定向到其配置的身份提供者通过 JacksonsignIn )来启动(通过调用)SAML SSO 流AuthContext

单击“继续使用 SAML SSO ”按钮时,用户将被重定向到 IdP 。



src/pages/Login.tsx

import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import useAuth from '../hooks/useAuth';

const Login = () => {
  let location = useLocation();

  let from = location.state?.from?.pathname || '/profile';

  const { signIn, setTenant, authStatus, user } = useAuth();

  if (authStatus !== 'LOADED') {
    return null;
  }

  if (authStatus === 'LOADED' && user) {
    return <Navigate to={from} replace />;
  }

  return (
    <div className="mx-auto h-screen max-w-7xl">
      <div className="flex h-full flex-col justify-center space-y-5">
        <h2 className="text-center text-3xl">Log in to App</h2>
        <div className="mx-auto w-full max-w-md px-3 md:px-0">
          <div className="rounded border border-gray-200 bg-white py-5 px-5">
            <form className="space-y-3" method="POST" onSubmit={signIn}>
              <label htmlFor="tenant" className="block text-sm">
                Tenant ID
              </label>
              <input
                type="text"
                name="tenant"
                placeholder="boxyhq"
                defaultValue="boxyhq.com"
                className="block w-full appearance-none rounded border border-gray-300 text-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500"
                required
                onChange={(e) =>
                  typeof setTenant === 'function' && setTenant(e.target.value)
                }
              />
              <button
                type="submit"
                className="w-full rounded border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white focus:outline-none"
              >
                Continue with SAML SSO
              </button>
            </form>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Login;


Enter fullscreen mode Exit fullscreen mode

获取用户资料

一旦accessToken获取,React 应用程序就可以使用它从身份提供者检索用户配置文件。

通常,您会使用后端服务(例如:Express.js)来调用SAML Jackson API来获取用户配置文件accessToken

让我们看一下在登录时或通过解析客户端 cookie 中的 JWT 返回用户配置文件的express.js路由。




app.get('/api/authenticate', async function (req, res, next) {
  const accessToken = req.query.access_token;

  if (!accessToken) {
    throw new Error('Access token not found.');
  }

  const response = await fetch(
    `${jacksonUrl}/api/oauth/userinfo?access_token=${accessToken}`,
    {
      method: 'GET',
    }
  );

  const profile = await response.json();

  // Once the user has been retrieved from the Identity Provider,
  // you may determine if the user exists in your application and authenticate the user.
  // If the user does not exist in your application, you will typically create a new record in your database to represent the user.

  const token = jsonwebtoken.sign(
    {
      id: profile.id,
      email: profile.email,
      firstName: profile.firstName,
      lastName: profile.lastName,
    },
    jwtSecret
  );

  res.cookie('sso-token', token, { httpOnly: true });
  res.json(profile);
});

app.get('/api/profile', async function (req, res, next) {
  const token = req.cookies['sso-token'];

  if (!token) {
    return res
      .status(401)
      .json({ data: null, error: { message: 'Missing JWT' } });
  }

  // You may fetch the user profile from your database using the user id.

  const payload = jsonwebtoken.verify(token, jwtSecret);

  return res.json({ data: payload, error: null });
});


Enter fullscreen mode Exit fullscreen mode

您可以检查终端并以 JSON 格式查看返回的配置文件。



{
  "id":"<id from the Identity Provider>",
  "email": "jackson@coolstartup.com",
  "firstName": "SAML",
  "lastName": "Jackson",
  "requested": {
    "tenant": "<tenant>",
    "product": "<product>",
    "client_id": "<client_id>",
    "state": "<state>"
  },
  "raw": {
    ...
  }
}


Enter fullscreen mode Exit fullscreen mode

getProfileByJWT在 React 应用程序中,如果已经拥有 access_token,我们会调用,或者authenticate当从 SSO 提供程序返回授权码时,我们会调用。



src/lib/backend.ts

const apiUrl = process.env.REACT_APP_API_URL;

export const authenticate = async (token: string | undefined) => {
if (!token) {
throw new Error('Access token not found.');
}

const response = await fetch(
</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/api/authenticate?access_token=</span><span class="p">${</span><span class="nx">token</span><span class="p">}</span><span class="s2">,
{
method: 'GET',
credentials: 'include',
}
);
if (response.ok) {
return await response.json();
}
return null;
};

export const getProfileByJWT = async () => {
const response = await fetch(</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/api/profile, {
method: 'GET',
credentials: 'include',
});

return await response.json();
};

Enter fullscreen mode Exit fullscreen mode




成功

就这样,你的 React 应用已经准备好处理单点登录身份验证了。🎉

关键要点

根据statista的数据,React 是开发人员使用的第二大流行框架,约占 40%。

在您自己的部署的应用程序中创建和实施 SAML 单点登录 (SSO) 的能力至关重要,因为它不仅可以增强安全性和用户便利性,还可以简化用户和管理员的访问控制。




如果您喜欢开源,请在评论中告诉我,或在Twitter (X)上联系我,告诉我您正在构建什么!文章来源:https://dev.to/nathan_tarbert/react-app-leveled-up-with-sso-auth-wizardry-3h2
PREV
用神经网络玩井字游戏
NEXT
通过构建一个简单的项目来理解 C/C++ 构建系统,包括“memory_api.h”应该是#include“free_memory_api.h”,以便教程按预期工作,我等不及第二部分了;)