使用JWT令牌在nodej中处理身份验证
#node #jwt #authentication #tokens

介绍

认证用户身份的过程涉及获得凭据并利用这些凭据来验证用户的身份。我们所有人在日常生活中都可以验证几项服务,例如登录PC,登录电子邮件等。它有助于我们获得对应用程序的个性化访问。身份验证可以帮助用户通过其数据维护隐私。

身份验证和授权之间的差异

对于这些术语如何传达不同的含义,一见钟情似乎令人困惑。人们通常会互换使用它,这是一个误解。虽然身份验证是验证用户是谁的过程。另一方面,授权是授予对特定资源的访问的过程,具体取决于用户必须访问它们的许可。

基于角色的访问控制(RBAC)ð©âð»

基于角色的访问控制是一种定义用户可以根据分配给他们的角色获得的权限的方法。角色将被分配做一些特定的操作或访问某些资源。在这里,当用户被授权使用某些功能或访问某些资源时,“授权”一词非常适合。

了解身份验证的客户服务器模型ð»

在这种情况下,我们定义了两种类型的JWT令牌,它们可以在客户端和服务器之间传递以获得授权,即accessTokenrefreshToken

  • accessToken:服务器仅在客户希望客户端访问受保护的路由时才发出访问令牌。 访问令牌是短暂的。

    注意:

    • 服务器发出访问令牌以授权客户端,因此这些令牌不应存储在持续的浏览器存储中,例如localStoragecookies。将它们存储的推荐方法是将其存储在内存存储中,以便在应用程序关闭时丢失。
    • accessToken被内存的原因是防止黑客访问这些令牌。由于内存存储无法访问JavaScript,这意味着即使黑客试图注入恶意JavaScript代码,也无法检索accessToken
  • refreshToken:用户对应用程序进行身份验证时会发出刷新令牌。 刷新令牌长期存在。

    注意:

    • refreshTokenhttpOnly cookie的形式存储在浏览器中,JavaScript无法访问并且仅在用户身份验证之前才有效,换句话说,仅当用户登录
    • 中时
    • refreshToken可以生成新的accessToken时,当kude12 rest api的端点被调用。
    • refreshToken的引用存储在数据库中,因此,如果用户决定提早注销,则可以删除存储在数据库中的refreshToken,并且可以终止会话
    • 存储在数据库中的refreshToken,的引用与refreshToken在client-request上存储在httpOnly cookie中的refreshToken进行了交叉验证,以恢复API以验证会话。
    • refreshToken不应被允许生成新的刷新口,以防止无授权的人无限期地访问,如果他们设法以某种方式获得refreshToken

后端实现

代码实现将使用客户端的React和后端侧的ExpressJ进行。 mongodb用作数据库。

下面提到了后端所需的package.json

// package.json for backend
"dependencies": {
    "bcrypt": "^5.1.0",
    "body-parser": "^1.20.1",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.1",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^6.5.4",
    "nodemon": "^2.0.19"
  }

发行refreshToken

  • 当用户登录应用程序

    const login = (req, res, next) => {
        const { email, password } = req.body;
        let errors = [];
        if (!email || !password) {
            errors.push({ msg: 'Please fill in all fields' });
            res.status(400).json({ errors });
        }
        User.findOne({ email })
            .then(user => {
                if (!user) {
                    errors.push({ msg: 'Email is not registered' });
                    res.status(400).json({ errors });
                } else {
                    bcrypt.compare(password, user.password, async (err, isMatch) => {
                        if (err) throw err;
                        if (isMatch) {
    // accessToken and refreshToken is being issued
                            const accessToken = jwt.sign(
                                {
                                    userInfo: {
                                        name: user.name,
                                        role: user.role
                                    },
                                }
                                , process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
                            const refreshToken = jwt.sign({ name: user.name, role: user.role }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '1d' });
    
                            // adding a refreshToken in mongodb
                            await User.findOneAndUpdate({ _id: user._id }, { refreshToken: refreshToken })
                            res.cookie('refreshToken', refreshToken, { maxAge: 1000 * 60 * 60 * 24, sameSite: 'none', httpOnly: true, secure: true });
                            res.status(200).json({ role: user.role, accessToken: accessToken });
                        } else {
                            errors.push({ msg: 'Password is incorrect' });
                            res.status(400).json({ errors });
                        }
                    })
                }
            })
    }
    
  • 验证refreshToken何时accessToken到期

    const User = require('../Models/user')
    const jwt = require('jsonwebtoken')
    
    const handleRefreshToken = async (req, res) => {
        const cookies = req.cookies;
        // ensure cookie is set
        if (!cookies?.refreshToken) res.status(401).json({ msg: 'No cookies' });
        const refreshToken = cookies.refreshToken;
    
        // cross verify refreshToken with the stored refreshToken in DB
        const person = await User.findOne({ refreshToken })
        if (!person) res.status(401).json({ msg: 'No person' });
    
        // evalutate jwt
        jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
            const role = Object.values(user.role)
            if (err) res.status(403).json({ msg: 'Invalid token' });
            // accessToken is issued on successful verification
            const accessToken = jwt.sign({
                userInfo: {
                    name: user.name,
                    role: user.role
                },
            }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
            res.status(200).json({ accessToken: accessToken, role: role });
        })
    
    }
    

在这里,我们正在使用AccessToken和Refreshtoken加密用户角色,以便在服务器端实现RBAC

当用户成功登录网站

refreshToken作为httpOnly cookie在响应标头中发出。 cookie设置在浏览器中,该浏览器将随后用于检索新的accessToken

accessToken存储在浏览器的内存中,因此看不到。但是登录控制台。我们验证它已发行。 在生产环境中,切勿将其记录到控制台。

accessToken作为请求授权标题中的携带者令牌传递,然后帮助客户访问受保护的路线。

我们创建一个中间件功能来验证accessToken

const verify = (req, res, next) => {
    const authHeader = req.headers.authorization || req.headers.Authorization;
    if(!authHeader?.startsWith('Bearer ')) return res.status(401).json({ msg: 'Unauthorized' });
    const token = authHeader.split(' ')[1];
    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded)=> {
        if(err) return res.status(403).json({ msg: 'Invalid token' });
        console.log(decoded)
        req.role = decoded.userInfo.role
        next()
    })
}

角色的验证是由另一个中间件功能完成的

const verifyRoles = (...allowedRoles) => {
    return (req, res, next) => {
        if (!req?.role) return res.status(401).json({ msg: 'You are not authenticated' });
        const rolesArray = [...allowedRoles]
        const result = req.role.map(role => rolesArray.includes(role)).find(val => val === true);
        if (!result) return res.status(401).json({ msg: 'You are not authorized to access this route' });
        next();
    }
}

在前端实施受保护的路线ð

react-router-dom v6允许我们根据分配给用户的角色保护路线。这些角色将根据AccessToken从后端获取。

实现如下:

App.jsx

import { Routes, Route } from "react-router-dom"
import Home from "./Pages/Home";
import Loginscreen from "./Pages/Loginscreen";
import Unauthorised from "./Components/Unauthorised";
import UserProvider from "./context/Usercontext";
import Admin from "./Pages/Admin";
import Layout from "./Layout";
import Registerscreen from "./Pages/Registerscreen";
import Missing from "./Missing";
import RequireAuth from "./RequireAuth";
import EditorScreen from "./Pages/EditorScreen";
import Persistlogin from "./Components/Persistlogin";

function App() {

  return (
    <UserProvider>
      <Routes>
          {/* Public Routes (without logged in) */}
          <Route path="/login" element={<Loginscreen />} />
          <Route path="/unauthorised" element={<Unauthorised />} />
          <Route path="/register" element={<Registerscreen />} />


          <Route element={<Persistlogin />}>
            {/* Any User can access with user role */}
            <Route element={<RequireAuth allowedRoles={[2000]} />}>
              <Route path="/" element={<Home />} />
            </Route>


            {/* Protected Routes (Admin) */}
            <Route element={<RequireAuth allowedRoles={[1000]} />}>
              <Route path="/admin" element={<Admin />} />
            </Route>

            {/* Protected Routes(Editor) */}
            <Route element={<RequireAuth allowedRoles={[3000]} />}>
              <Route path="/editor" element={<EditorScreen />} />
            </Route>
          </Route>

          {/* Catch all */}
          <Route path="*" element={<Missing />} />
      </Routes>
    </UserProvider>
  );
}

export default App;

创建了requireAuth.jsx组件来管理受保护的路由访问

import { useLocation, Navigate, Outlet } from "react-router-dom"
import useAuth from "./hooks/useAuth"

const RequireAuth = ({ allowedRoles }) => {
    // useAuth is a custom hook created to access the global user context store
    const { auth } = useAuth()
    const location = useLocation()

    return (
        <div>
            {auth?.role?.find(role1 => allowedRoles.includes(role1))
                ? <Outlet />
                : auth?.email ?
                    <Navigate to="/unauthorised" state={{ from: location }} replace />
                    : <Navigate to="/login" state={{ from: location }} replace />
            }
        </div>
    )
}

<Outlet/>组件是react-router-dom v6的内置功能,有助于渲染包裹在<Route></Route>组件周围的children组件。

当用户试图刷新页面时,一个主要问题是一个主要问题,当窗口刷新窗口时,AUTH状态将重置。为了保持持久登录状态,我们定义另一个组件PersistLogin.jsx

import useRefreshToken from "../hooks/useRefreshToken"
import useAuth from "../hooks/useAuth"
import { useState, useEffect } from "react"
import { Outlet } from "react-router-dom"

const Persistlogin = () => {
    const refresh = useRefreshToken()
    const { auth } = useAuth()
    const [loading, setLoading] = useState(true)

    useEffect(() => {
        const verifyRefreshToken = async () => {
            try {
                await refresh()
            } catch (error) {
                console.error(error)
            }
            finally {
                setLoading(false)
            }

        }
        !auth?.accessToken ? verifyRefreshToken() : setLoading(false)
    }, [])

    useEffect(() => {
        console.log(`loading: ${loading}`)
        console.log(`accessToken: ${JSON.stringify(auth?.accessToken)}`)
    }, [loading])

    return (
        <>
            {loading ? <p>Loading...</p> : <Outlet />}
        </>
    )
}

export default Persistlogin

访问受保护的路线

当管理员登录应用程序并尝试访问管理页面时,他将获得访问权限。

但是当管理员导航到编辑页面时,他会收到以下错误。

accessToken到期后,/refresh端点被打击以获取新的accessToken

展示了网络活动:

/getUser路线受到保护,当通过过期的访问访问访问时,我们会得到403禁止响应。 refreshToken随后被调用以获取新的访问权限。这是在Axios拦截器的帮助下实现的。

Axios拦截器

让我们演示有关新的accessTokens在使用Axios interceptor

过期时从服务器中获取的方式

Axios是一个强大的基于承诺的HTTP库。它最酷的功能之一是Axios拦截器。

Axios拦截器与ExpressJS的中间件功能相似。

请求拦截器帮助我们定义是否要在向服务器发送请求之前进行任何操作。

axios.interceptors.request.use(function (config) {
    // Do something before request is sent
    return config;
  }, function (error) {
    // Do something with request error
    return Promise.reject(error);
  });

在我们的应用程序中,我们定义了请求拦截器将AccessToken通过作为授权标题中的承载令牌。

const requestIntercept = axiosPrivate.interceptors.request.use(
            config => {
                if (!config.headers['Authorization']) {
                    config.headers['Authorization'] = `Bearer ${auth?.accessToken}`
                }
                return config
            },
            error => Promise.reject(error)
        )

类似地,

响应拦截器被定义为当我们收到服务器的响应后要进行任何操作时。

axios.interceptors.response.use(function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    return response;
  }, function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    return Promise.reject(error);
  });

在此应用程序中,我们已经实施了一个响应拦截器,以重新将accessToken作为请求标头重新传输,一旦我们检测到403禁止状态,该状态将用于过期的accessToken

const reponseIntercept = axiosPrivate.interceptors.response.use(
// if successful return response
            (response) => response,
            async (error) => {
// if there is an error, which comes from expired accessToken
                const prevRequest = error?.config;
                if (error?.response?.status === 403 && !prevRequest?.sent) {
// issue new accessToken from refresh endpoint and send it to the request header
                    const newAccessToken = await refresh()
                    return axiosPrivate({
                        ...prevRequest,
                        headers: { ...prevRequest.headers, Authorization: `Bearer ${newAccessToken}` },
                        sent: true
                    });
                }
                return Promise.reject(error);
            }
        )

登录用户

当用户尝试注销时,删除了浏览器存储的httpOnly cookie存储的refreshToken。 DB存储的refreshToken也被同时清除。

const logout = (req, res) => {
    const cookies = req.cookies;
    if (!cookies?.refreshToken) res.status(204).json({ msg: 'No cookies' });
    const refreshToken = cookies.refreshToken;
    User.findOneAndUpdate({ refreshToken }, { refreshToken: '' }, (err, doc) => {
        if (err) {
            res.status(500).json({ msg: 'Something went wrong' })
        }
        // maxAge need not be set during clearCookie
        res.clearCookie('refreshToken', { httpOnly: true, sameSite: 'none', secure: true });
        res.status(200).json({ msg: 'Logged out' });
    })
}

要记住的事情

交叉原始资源访问

我们的React应用程序在http://localhost:3000/上运行

和我们在http://localhost:5000/上运行的后端Express应用

很明显,我们正在尝试从后端访问其他来源,即我们的React App。

这将给我们带来CORS错误,该错误可以通过阻止各种恶意的跨站点脚本活动来保持站点的安全。

在开发环境中,我们可能会遇到此错误,因此,为防止它,我们使用CORS中间件,指定始终可以通过此策略的来源。

CORS中间件可以由此命令

安装

npm i corsyarn add cors

const allowedOrigin = [
    'http://localhost:3000',
    'http://localhost:5000',
    'http://localhost:3001',
    'http://localhost:5001',
]

const corsOption = {
    origin: (origin, cb)=> {
        if(allowedOrigin.indexOf(origin) !== -1 || !origin){
            cb(null, true)
        }
        else{
            cb(new Error("Not allowed by CORS"))
        }
    },
    optionsSuccessStatus: 200,
}

我们通过CORS中间件中的corsOption如下

app.use(corsOption)

配置cookie允许CORS

后端:

发送和接收cookie时,请确保设置secure: true,然后sameSite: 'none'允许cookie共享cross-origin

res.cookie('refreshToken', refreshToken, { maxAge: 1000 * 60 * 60 * 24, sameSite: 'none', httpOnly: true, secure: true });

同样,

res.clearCookie('refreshToken', { httpOnly: true, sameSite: 'none', secure: true });

注意:要通过Postman或任何其他类似的应用程序测试后端功能,请确保设置secure: false,因为它不允许在应用程序中设置Cookie,最终导致错误。

前端,

在提出Axios请求时,请确保设置withCredentials: true以允许浏览器在提出请求之前设置cookie。

await axios.get('/refresh', { withCredentials: true })

资源链接

提供了一些有用资源的链接,您可以将其引用,以获取此顶部的更多深入知识。 ð

非常感谢您阅读我的文章。 -