在Next.js中创建基本身份验证提供商
#javascript #react #nextjs #authentication

那里有几个身份服务提供商,将开发商板的复杂性和安全考虑因素带走。 Next.js也有身份验证库,例如Next-auth 。但是有时您想为不涉及敏感用户数据的简单Web应用程序实现非常基本的身份验证。

在本教程中,我们将引导您完成Next.js中创建基本身份验证提供商的过程。无论您是经验丰富的开发人员还是刚从Next.js开始,您都会发现本教程有助于建立基本的身份验证提供商。所以让我们开始吧!


验证提供者的解剖学

我们的身份验证流将由两个部分组成:

  1. 一个反应上下文提供商和useAuth
  2. 一个身份验证和服务用户数据的后端API

这是我们的身份验证流的运作方式:

  1. 用户试图访问受保护的页面
  2. useAuth检查是否有登录cookie,如果不是登录页面
  3. 用户输入登录和密码
  4. 后端API接收登录数据,并在DB中查找用户
  5. 生成了独特的令牌,并将其返回给客户端
  6. useAuth设置当前用户状态并设置一个包含登录令牌的cookie
  7. 随后刷新的用户数据来自登录cookie数据
  8. 登录cookie数据已针对背景中的新鲜API数据进行验证

Authentication Flow


设置我们的项目

可以在页面底部找到本教程的演示站点和回购。该演示是用npx create-next-app@latest与打字稿和尾风CSS创建的。

依赖项:

中管理cookie的软件包
软件包名称 目的
cookie 包装cookie从请求数据
cookie-next 用于在Next.js
bcryptjs 用于哈希和比较密码的软件包

安装类型:
npm i -D @types/bcryptjs @types/cookie


创建我们的上下文提供商

我们将使用React的useContext钩创建我们的身份验证上下文:

/**
 * Basic Authentication Provider
 * 
 * @author Jay Simons
 * 
 */

import React, { useState, useEffect, useContext, createContext, ReactNode } from "react";
import { getCookie, setCookie, deleteCookie } from 'cookies-next';
import { useRouter } from "next/router";

import { User, LoginToken, LoginCookie, LoginProps, AuthContextType } from '@/interfaces';

const COOKIE_KEY = process.env.NEXT_PUBLIC_AUTH_COOKIE_KEY || '';

const authContext = createContext<AuthContextType>({
    user: null,
    login: async () => false,
    logout: async () => { },
    reload: () => { },
    sendPasswordResetEmail: () => { },
    setLoginCookie: () => { },
});

// Auth provider context
export function AuthProvider({ children }: { children: ReactNode }) {
    const auth = useAuthProvider();
    return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}

// Hook for child components to get the auth object ...
// ... and re-render when it changes.
export const useAuth = () => {
    return useContext(authContext);
};

// Provider hook that creates auth object and handles state
function useAuthProvider() {
    const [user, setUser] = useState<User | null>(null);
    const cookieData = getCookie(COOKIE_KEY);
    const router = useRouter();

    const login = async (props: LoginProps) => {
        const result = await fetch("/api/auth/login", {
            method: 'POST',
            body: JSON.stringify(props),
            headers: {
                'Content-Type': 'application/json'
            }
        });
        if (result.ok) {
            const data = await result.json();
            setUser(data.user);
            setLoginCookie(data);
            return true;
        } else {
            const error = await result.text();
            const errMess = result.status === 401 ? "Invalid username or password" : 'Login failed';
            console.error(error);
            throw new Error(errMess);
        }
    };

    // Tell the API we're loggin out and delete out login cookie
    const logout = async () => {
        const result = await fetch('/api/auth/logout');
        if (!result.ok) throw new Error("Log out failed");
        deleteLoginCookie();
        setUser(null);
        return;
    };

    // Store login token / user data in cookie
    const setLoginCookie = (data: LoginCookie) => {
        const expires = new Date(data.token.expires);
        setCookie(COOKIE_KEY, data, { path: '/', expires: expires });
    }

    // Delete login cookie
    const deleteLoginCookie = () => {
        deleteCookie(COOKIE_KEY);
    }

    // Pull user data from API
    const loadUser = async () => {
        console.log("Pulling user data...");
        async function fetchUser(cookieParsed: LoginCookie) {
            try {
                const result = await fetch("/api/auth/me");
                if (result.ok) {
                    const user = await result.json();
                    console.log("Done fetching user!");
                    setUser(user);
                    cookieParsed.user = user;
                    setLoginCookie(cookieParsed);
                } else {
                    setUser(null);
                    deleteLoginCookie();
                }
            } catch (err) {
                console.error(err);
            }
        }

        if (typeof cookieData === 'string') {
            const cookieParsed = JSON.parse(cookieData);
            setUser(cookieParsed.user);
            fetchUser(cookieParsed);
        } else {
            setUser(null);
        }
    }

    // Reload user data
    const reload = () => {
        setTimeout(() => {
            loadUser();
        }, 200);
    }

    // Send password reset email
    const sendPasswordResetEmail = (email: string) => {
        /* TODO */
    };

    // Subscribe to user on mount
    useEffect(() => {
        loadUser();
    }, []); //eslint-disable-line

    // Return the user object and auth methods
    return {
        user,
        login,
        logout,
        reload,
        sendPasswordResetEmail,
        setLoginCookie,
    };
}

为提供商创建authContext并定义AuthProvideruseAuth函数:

本节定义了authContext和两个函数:

  • AuthProvider:包装应用程序并为其子零件提供authContext的组件。
  • useAuth:一个由子组件用来访问auth对象的钩子。

定义useAuthProvider

本节定义了useAuthProvider钩,这是该代码的核心功能。它管理用户状态,处理服务器的身份验证请求,并将用户数据存储在cookie中。 useAuthProvider中的功能是:

  • login:将邮政请求发送给用户凭据,并设置用户状态和cookie数据,如果成功。
  • logout:向服务器发送请求以注销用户并删除用户状态和cookie数据。
  • setLoginCookie:在cookie中存储登录令牌和用户数据。
  • deleteLoginCookie:从cookie中删除登录令牌和用户数据。
  • loadUser:如果请求成功,请从服务器检索用户数据,并设置用户状态和cookie数据。
  • reload:从服务器重新加载用户数据的新副本。
  • sendPasswordResetEmail:发送密码重置电子邮件(目前未实施)。

导出AuthProvideruseAuth函数:

本节导出了AuthProvideruseAuth函数,以便可以在其他组件中使用。


在我们的应用中实施

首先,我们需要打开_app.tsx并在我们的AuthProvider上下文中包装所有内容:

import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { Inter } from 'next/font/google'
import { AuthProvider } from '@/hooks/useAuth';

const inter = Inter({ subsets: ['latin'] })

export default function App({ Component, pageProps }: AppProps) {
  return (
    <AuthProvider>
      <main className={inter.className}>
        <Component {...pageProps} />
      </main>
    </AuthProvider>
  )
}

接下来,我们需要一种处理受保护和未保护的页面的方法。实现此目的的最佳方法是在布局高阶组件中进行操作:

import React, { ReactNode } from 'react'
import Head from 'next/head'
import Footer from './Footer'
import LoginWrapper from '../Login'

import { useAuth } from '@/hooks/useAuth'

export default function Layout(props: {
    children: ReactNode,
    pageTitle?: string,
    requireAuth?: boolean
}) {
    const auth = useAuth();

    const {
        children,
        pageTitle,
        requireAuth = false
    } = props;

    return (
        <div className="min-h-screen flex flex-col bg-gradient-to-b from-slate-600 to-slate-700 text-white">
            {
                requireAuth && !auth.user
                    ?
                    <LoginWrapper />
                    :
                    <>{children}</>
            }
            <Footer />
        </div>
    )
}

此组件将有条件地渲染<LoginWrapper>{children},具体取决于我们是否设置requireAuth标志。

这是<LoginWrapper>

import React, { useEffect, useState } from 'react'
import Login from './Login'

/**
 * This component is used to wrap the login page.
 * It delays showing the login page
 * to give time for auth.user to load
 * 
 * @author Jay Simons
*/
export default function LoginWrapper() {
    const [showLogin, setShowLogin] = useState<boolean>(false);

    useEffect(() => {
        setTimeout(() => {
            setShowLogin(true);
        }, 1000);
    }, []);

    if (showLogin) {
        return (
            <Login />
        )
    } else {
        return (
            <div className="flex h-screen">
                <div className="m-auto text-2xl fade-text">Checking Login...</div>
            </div>
        )
    }
}

我们使用useEffect等一秒钟,然后显示登录组件。这是为了给useAuth时间从cookie或server获取用户数据。

接下来是我们的登录表单组件:

import React, { useState } from 'react'
import Link from 'next/link'

import { useAuth } from '@/hooks/useAuth'

export default function Login() {
    const auth = useAuth();
    const [email, setEmail] = useState<string>('joeblow');
    const [password, setPassword] = useState<string>('TestPassword4$');
    const [error, setError] = useState<string>('');
    const [loading, setLoading] = useState<boolean>(false);

    const handleResetPassword = () => {
        // TODO: Implement
    }

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        if (e.target.name === 'email') {
            setEmail(e.target.value);
        } else if (e.target.name === 'password') {
            setPassword(e.target.value);
        }
    }

    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();

        setLoading(true);
        try {
            await auth.login({
                username: email,
                password
            });
        } catch (err) {
            if (err instanceof Error) {
                setError(err.message);
            }
        } finally {
            setLoading(false);
        }
    }

    return (
        <div className="m-auto w-full md:w-[300px] flex flex-col gap-6">
            <h1 className="text-center text-2xl font-medium">Please Log In</h1>
            {error && <p className="text-center text-red-500">{error}</p>}
            <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
                <input
                    name="email"
                    className="border border-gray-300 p-2 rounded-md text-gray-800"
                    type="text"
                    placeholder="Username or Email"
                    onChange={handleInputChange}
                    defaultValue="joeblow"
                />
                <input
                    name="password"
                    className="border border-gray-300 p-2 rounded-md text-gray-800"
                    type="password"
                    placeholder="Password"
                    onChange={handleInputChange}
                    defaultValue="TestPassword4$"
                />
                <button
                    className={`btn-base ${loading ? 'bg-gray-500 opacity-50' : 'bg-blue-500 hover:bg-blue-600'}`}
                    type="submit"
                    disabled={loading}
                >
                    {loading ? <span className="fade-text">Loading...</span> : 'Log In'}
                </button>
            </form>
            <p className="text-center">
                Don&apos;t have an account?{' '}
                <Link className="link" href="/login">Register</Link>
            </p>
            <p className="text-center">
                Forgot your password?{' '}
                <button
                    className="link"
                    onClick={handleResetPassword}
                >Reset</button>
            </p>
        </div>
    )
}

非常直接。我们的handleSubmit功能只需使用提供的登录ID和密码调用auth.login。请注意,登录ID可以是用户名或电子邮件地址。


处理后端

我们有三个端点要定义:

  1. /api/auth/login-负责身份验证用户名和密码
  2. /api/auth/login-负责从用户记录中删除登录令牌
  3. /api/auth/me-负责基于登录cookie
  4. 从服务器提供新的用户数据

这是我们的登录端点:

import fakeDb from "@/util/fakeDb";
import { compareSync } from "bcryptjs";
import sanitizeUser from "@/util/sanitizeUser";

// Use edge runtime to improve performance.
export const config = {
    runtime: 'edge'
}

export async function unauth() {
    return new Response('Unauthorized', { status: 401 });
}

export default async function handler(request: Request) {
    try {
        const data = await request.json();
        let user;
        // Fetch user from database
        try {
            user = await fakeDb(data.username);
        } catch (err) {
            return unauth();
        }
        // Check if user exists
        if (!user) return unauth();
        // Verify password
        if (!compareSync(data.password, user.password)) {
            return unauth();
        }
        // Generate token
        const token = user.tokens[0]; // Normally we would generate one

        // Return user and token
        return new Response(JSON.stringify({
            user: sanitizeUser(user),
            token
        }), {
            headers: {
                'Content-Type': 'application/json'
            }
        });
    } catch (err) {
        if (err instanceof Error) {
            return new Response(err.message, { status: 500 });
        } else {
            return new Response('Unknown error', { status: 500 });
        }

    }
}

在我的示例中,我正在使用Edge运行时进行超快速速度,但是如果您喜欢

我创建了一个fakeDb函数,该功能模拟了对数据库的调用,以稍微延迟获取用户数据。您可以在下面的存储库上找到该代码。

最后,这是我们的/api/auth/me端点的代码:

import fakeDb from "@/util/fakeDb";
import sanitizeUser from "@/util/sanitizeUser";
import { parse } from "cookie";

// Use edge runtime to improve performance.
export const config = {
    runtime: 'edge'
}

export async function unauth() {
    return new Response('Unauthorized', { status: 401 });
}

export default async function handler(request: Request) {
    try {
        // Fetch login cookie
        const cookies = parse(request.headers.get('Cookie') || '');
        const cookie = cookies[process.env.NEXT_PUBLIC_AUTH_COOKIE_KEY || ''];
        if (!cookie) return unauth();
        const cookieData = JSON.parse(cookie);

        // Check if cookie is valid
        if (!cookieData.token || !cookieData.token.token || !cookieData.token.expires) return unauth();
        if (new Date(cookieData.token.expires) < new Date()) return unauth();

        // Fetch user from database
        let user;
        try {
            user = await fakeDb(cookieData.token.token);
        } catch (err) {
            return unauth();
        }

        // Check if user exists
        if (!user) return unauth();

        // Return user and token
        return new Response(JSON.stringify(sanitizeUser(user)), {
            headers: {
                'Content-Type': 'application/json'
            }
        });
    } catch (err) {
        if (err instanceof Error) {
            return new Response(err.message, { status: 500 });
        } else {
            return new Response('Unknown error', { status: 500 });
        }

    }
}

与登录端点非常相似,只是我们从登录cookie中的存储令牌中标识了用户,而不是提供的登录ID和密码。我们还希望确保检查令牌是否未过期。通常,您会在DB服务器上运行的清理CRON作业,以检查已过期的令牌,但我们不想仅依靠它。

我离开了注销端点的代码,因为它只是返回空白200响应。在生产环境中,您通常会包含代码以从数据库中的用户记录中删除登录令牌。在此演示中,登录时仅删除客户端cookie。


链接

  1. GitHub Repo
  2. Demo Page

感谢您抽出宝贵的时间阅读我的文章,希望您发现它很有用(或至少很有趣)。有关Web开发,系统管理和云计算的更多信息,请阅读Designly Blog。另外,请留下您的评论!我喜欢听读者的想法。

我使用Hostinger托管我的客户网站。您可以获得一个可以以$ 3.99/mo的价格托管100个网站的业务帐户,您最多可以锁定48个月!这是镇上最好的交易。服务包括PHP托管(带有扩展),MySQL,WordPress和电子邮件服务。

寻找网络开发人员?我可以租用!要查询,请填写一个contact form