😎 React App 通过开源 SSO Auth Wizardry 升级 🪄
TL;DR
我是 Nathan,我喜欢的事情之一是 React 开发。
在本教程结束时,您将拥有一个合适的 React 应用程序,该应用程序将通过 SAML 单点登录对用户进行身份验证。
在深入讨论实施细节之前,请确保您已做好以下准备:
-
用于启用 SAML 单点登录的 React 应用程序。
-
了解配置 SAML 单点登录和SSO 连接 API 的UI 最佳实践(这些概念对于将 SAML SSO 集成到您的应用程序中至关重要)。
-
由于我们使用 API,我建议使用开源 Postman 替代方案Firecamp来测试你的路由。
请为 Firecamp 点赞⭐
目录
- 集成步骤概述
- 使用 SAML 单点登录进行身份验证
- 部署 SAML Jackson
- 设置 SAML Jackson 集成
- 设置全局身份验证原语
- 授权提供者
- 身份验证请求
- 获取用户资料
- 成功
- 关键要点
- 为什么你应该关心 SSO
为什么你应该关心 SSO
SAML SSO 支持无缝、安全的身份验证,减少了用户记住多个密码的需要,并最大限度地降低了与密码相关的安全漏洞的风险。
它还允许企业集中管理用户访问和权限,从而更容易控制谁有权访问应用程序以及他们可以在其中做什么。
通过集成 SAML SSO,您的应用程序符合身份验证和访问控制的行业标准,提供值得信赖且高效的用户体验,同时增强应用程序的安全性和管理功能。
现在我们已经满足了先决条件,现在可以开始为用户启用SAML单点登录身份验证了。🎉
我提到过 SAML...你可能听说过他?❤️
集成步骤概述
将 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
接下来,配置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;
}
设置全局身份验证原语
让我描述一下我们将如何使用AuthContext
。
为了使身份验证过程在我们的整个应用程序中全局可访问,我们将创建一个AuthContext
用于存储有关登录user
以及signIn
和signOut
方法的信息。
这些,连同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!);
下一个任务是创建一个自定义钩子,返回一个句柄AuthContext
。
创建新文件:
src/hooks/useAuth.ts
import { useContext } from 'react';
import { AuthContext } from '../lib/AuthProvider';
const useAuth = () => {
return useContext(AuthContext);
};
export default useAuth;
授权提供者
接下来,我们将连接内部的流程AuthProvider
。
- 一旦应用程序外壳被渲染,就会运行 useEffect 来引导流程至
authClient
fromuseOAuthClient
。
这里需要处理两种情况。
-
我们从 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 };
- 当有人尝试访问受保护/私人路线时,他们将
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;
接下来我们将使用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 });
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>
);
身份验证请求
让我们添加一个页面来开始身份验证流程。
该页面通过将用户重定向到其配置的身份提供者(通过 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;
获取用户资料
一旦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 });
});
您可以检查终端并以 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": {
...
}
}
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();
};
成功
就这样,你的 React 应用已经准备好处理单点登录身份验证了。🎉
关键要点
根据statista的数据,React 是开发人员使用的第二大流行框架,约占 40%。
在您自己的部署的应用程序中创建和实施 SAML 单点登录 (SSO) 的能力至关重要,因为它不仅可以增强安全性和用户便利性,还可以简化用户和管理员的访问控制。
如果您喜欢开源,请在评论中告诉我,或在Twitter (X)上联系我,告诉我您正在构建什么!文章来源:https://dev.to/nathan_tarbert/react-app-leveled-up-with-sso-auth-wizardry-3h2