使用 Context API 实现可预测的 React 身份验证
尽管网上有很多关于 React 和身份验证的教程,但我觉得过去几个月里我已经向太多我指导过的学生讲解过这部分内容了,所以现在是时候把它分享给更多的人了。身份验证是整个应用程序都需要关注的问题,因此也是一个全局状态问题。对很多人来说,在 React 中维护全局状态是一项棘手的任务,但是 React 提供了一种很好的方式来处理“某种程度上的”全局状态,那就是 Context API 和 Hooks。让我们来看看它是如何实现的。
赞美背景
useContext这是我们目前最好的选择。我经常用它来处理复杂的、应用级别的状态管理,甚至在较小的多组件 API 中也经常用到它,比如创建一个可复用的下拉组件(reach-ui团队就是这么做的)。如果你之前从未听说过 context API ,那么 Kent 的博客文章是了解它以及如何有效使用它的绝佳途径。
因此,为了管理身份验证,我们将使用 React 的 context API 使其可供应用程序中的每个组件使用,这样您就可以轻松地在您的项目中实现经典的登录/注销/注册逻辑。
一些免责声明
我假设您已经搭建好了后端。我将向您展示的示例都包含在我们的Phoenix 入门模板中。您可以将这里的 API 调用替换为您可用的任何代码。本教程中的所有代码都在那里。
此外,这种方法可能不太适合第三方 OAuth 提供商。要与 Auth0、Google、Facebook 等提供商集成,您应该使用它们自己的 SDK,而不是使用我接下来要介绍的方法。这样做更简单,而且它们的工具通常会处理所有这些问题。
在 Finiam,我们的工作通常是将我们自己的身份验证 API 与后端一起推出,或者使用客户端正在使用的任何 API,而客户端很少使用 OAuth 提供商。
开始编码
因此,对于我们的身份验证管理组件,我们有一些基本要求:
- 允许提交登录信息
- 允许提交注册信息
- 允许用户注销
- 应用加载时,检查当前用户是否已登录。
该计划是使用 React 的 context API 为整个应用程序提供这些操作,并通过一个简单的useAuthhook 使它们可用,从而允许我们读取和操作身份验证。
第一步是与你的身份验证后端通信。我们将使用Redaxios发起简单的 HTTP 请求。我们只需与几个操作服务器端 cookie 的端点通信,即可管理身份验证。无需发送授权标头或管理令牌,因为所有身份验证都在服务器端处理,浏览器只需接收即可。我们只需发起 HTTP 请求,服务器会处理一切!
如果你的后端使用类似 JWT 的 bearer token,你可以使用localStorage这种方法。你只需要修改 HTTP 客户端,使其在后续所有请求中使用返回的 token。你也可以将其存储在本地存储中,这样用户就无需每次都登录。但请注意,对于 Web 应用程序而言,服务器端 cookie 身份验证仍然是最佳选择!请查看这篇博客文章以获取更详细的解释。避免使用 localStorage。
用于与会话 API 交互的代码,该 API 处理登录和注销。api/sessions.tsx
import redaxios from "redaxios";
export async function login(params: {
email: string;
password: string;
}): Promise<User> {
const response = await redaxios.post("/api/sessions", { session: params });
return response.data.data;
}
export async function logout() {
const response = await redaxios.delete("/api/sessions");
return response.data.data;
}
以及与用户 API 交互的代码,用于注册用户或获取会话中当前已认证的用户。api/users.tsx
import redaxios from "redaxios";
export async function getCurrentUser(): Promise<User> {
const response = await redaxios.get("/api/user");
return response.data.data;
}
export async function signUp(params: {
email: string;
name: string;
password: string;
}): Promise<User> {
const response = await redaxios.post("/api/user", { user: params });
return response.data.data;
}
以上所有方法在出现问题时都会抛出错误。例如验证错误、密码错误、用户未登录以及网络错误等其他问题。
接下来,我们来看一下上下文 API 的相关内容。
useAuth.tsx
import React, {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useHistory, useLocation } from "react-router-dom";
import * as sessionsApi from "./api/sessions";
import * as usersApi from "./api/users";
interface AuthContextType {
// We defined the user type in `index.d.ts`, but it's
// a simple object with email, name and password.
user?: User;
loading: boolean;
error?: any;
login: (email: string, password: string) => void;
signUp: (email: string, name: string, password: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType>(
{} as AuthContextType
);
// Export the provider as we need to wrap the entire app with it
export function AuthProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [user, setUser] = useState<User>();
const [error, setError] = useState<any>();
const [loading, setLoading] = useState<boolean>(false);
const [loadingInitial, setLoadingInitial] = useState<boolean>(true);
// We are using `react-router` for this example,
// but feel free to omit this or use the
// router of your choice.
const history = useHistory();
const location = useLocation();
// If we change page, reset the error state.
useEffect(() => {
if (error) setError(null);
}, [location.pathname]);
// Check if there is a currently active session
// when the provider is mounted for the first time.
//
// If there is an error, it means there is no session.
//
// Finally, just signal the component that the initial load
// is over.
useEffect(() => {
usersApi.getCurrentUser()
.then((user) => setUser(user))
.catch((_error) => {})
.finally(() => setLoadingInitial(false));
}, []);
// Flags the component loading state and posts the login
// data to the server.
//
// An error means that the email/password combination is
// not valid.
//
// Finally, just signal the component that loading the
// loading state is over.
function login(email: string, password: string) {
setLoading(true);
sessionsApi.login({ email, password })
.then((user) => {
setUser(user);
history.push("/");
})
.catch((error) => setError(error))
.finally(() => setLoading(false));
}
// Sends sign up details to the server. On success we just apply
// the created user to the state.
function signUp(email: string, name: string, password: string) {
setLoading(true);
usersApi.signUp({ email, name, password })
.then((user) => {
setUser(user);
history.push("/");
})
.catch((error) => setError(error))
.finally(() => setLoading(false));
}
// Call the logout endpoint and then remove the user
// from the state.
function logout() {
sessionsApi.logout().then(() => setUser(undefined));
}
// Make the provider update only when it should.
// We only want to force re-renders if the user,
// loading or error states change.
//
// Whenever the `value` passed into a provider changes,
// the whole tree under the provider re-renders, and
// that can be very costly! Even in this case, where
// you only get re-renders when logging in and out
// we want to keep things very performant.
const memoedValue = useMemo(
() => ({
user,
loading,
error,
login,
signUp,
logout,
}),
[user, loading, error]
);
// We only want to render the underlying app after we
// assert for the presence of a current user.
return (
<AuthContext.Provider value={memoedValue}>
{!loadingInitial && children}
</AuthContext.Provider>
);
}
// Let's only export the `useAuth` hook instead of the context.
// We only want to use the hook directly and never the context component.
export default function useAuth() {
return useContext(AuthContext);
}
现在,该useAuth.tsx文件同时导出了AuthProvider和useAuth。为了使用钩子,我们需要用提供程序包装整个应用程序(或需要身份验证的部分)。
App.tsx
import React from "react";
import useAuth, { AuthProvider } from "./useAuth";
function InnerApp() {
const { user, loading, error, login, signUp, logout } = useAuth();
// Do whatever you want with these!
}
export default function App() {
return (
<AuthProvider>
<InnerApp />
</AuthRouter>
);
}
现在我先简要介绍InnerApp一下,因为我要向你们展示一下它在更“生产线”环境中的实际应用效果。我们将集成react-router这个钩子来创建登录和注册页面,并添加受保护的路由。
首先,我们创建两个页面组件,一个用于用户注册,另一个用于登录。
SignUpPage/index.tsx
import React, { FormEvent } from "react";
import { Link } from "react-router-dom";
import useAuth from "../useAuth";
// Just regular CSS modules, style, however, you desire
import styles from "./index.module.css";
// This is a uncontrolled form! No need to manage state for each input!
export default function SignUpPage() {
const { signUp, loading, error } = useAuth();
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
signUp(
formData.get("email") as string,
formData.get("name") as string,
formData.get("password") as string
);
}
return (
<form className={styles.root} onSubmit={handleSubmit}>
<h1>Sign up</h1>
{/*
On a real world scenario, you should investigate
the error object to see what's happening
*/}
{error && <p className={styles.error}>Sign up error!</p>}
<label>
Name
<input name="name" />
</label>
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
{/*
While the network request is in progress,
we disable the button. You can always add
more stuff, like loading spinners and whatnot.
*/}
<button disabled={loading}>Submit</button>
<Link to="/login">Login</Link>
</form>
);
}
现在,进入登录页面。LoginPage/index.tsx
import React, { FormEvent } from "react";
import { Link } from "react-router-dom";
import useAuth from "../useAuth";
import styles from "./index.module.css";
// Again, uncontrolled forms!
export default function Login() {
const { login, loading, error } = useAuth();
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
login(
formData.get("email") as string,
formData.get("password") as string
);
}
return (
<form className={styles.root} onSubmit={handleSubmit}>
<h1>Login</h1>
<label>
Email
<input name="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
<button disabled={loading}>Submit</button>
{/*
As I said above, these errors can happen for
more reasons, like network errors.
Control these as you desire!
*/}
{error && <p className={styles.error}>Bad login/password</p>}
<Link to="/sign_up">Sign Up</Link>
</form>
);
}
最后,我们添加一个非常简单的首页,以便用户登录后可以访问某个页面:HomePage/index.tsx
import React from "react";
import useAuth from "../useAuth";
import styles from "./index.module.css";
export default function HomePage() {
const { user, logout } = useAuth();
return (
<div className={styles.root}>
<p>Hello {user!.email}</p>
<button type="button" onClick={logout}>
Logout
</button>
</div>
);
}
现在,让我们回到应用程序的根目录。我们将使用它react-router-dom来构建应用程序的路由,并且还会添加一种保护路由的方法,以便只有登录用户才能访问它们。
App.tsx
import React from "react";
import {
BrowserRouter,
Switch,
Route,
RouteProps,
Redirect
} from "react-router-dom";
import useAuth, { AuthProvider } from "./useAuth";
import HomePage from "./HomePage";
import LoginPage from "./LoginPage";
import SignUpPage from "./SignUpPage";
// As the router is wrapped with the provider,
// we can use our hook to check for a logged in user.
function AuthenticatedRoute({ roles, ...props }: RouteProps) {
const { user } = useAuth();
if (!user) return <Redirect to="/login" />;
return <AsyncRoute {...props} />;
}
function Router() {
return (
<Switch>
<AuthenticatedRoute
exact
path="/"
component={HomePage}
/>
<Route
exact
path="/login"
component={LoginPage}
/>
<Route
exact
path="/sign_up"
component={SignUpPage}
/>
</Switch>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Router />
</AuthProvider>
</BrowserRouter>
);
}
现在您拥有了受保护的路由,可以将匿名用户重定向到登录页面!
总结
希望这对您有所帮助!这与我们生产环境中的场景非常接近,大部分逻辑都在这里。添加一些完善的错误处理机制,就大功告成了!
如果您想了解实际应用,请查看我们的 Phoenix/React 入门项目。代码与本教程中展示的并不完全相同,并且会随着时间的推移和需求的变化而进行调整,但它始终是一个很好的起点,并且已经处理好了身份验证。
注意安全👋
文章来源:https://dev.to/finiam/predictable-react-authentication-with-the-context-api-g10