React应用中JWT登录鉴权的完整实现

217 阅读4分钟

JWT登录鉴权在前端应用中的实现与实践

引言

在现代Web应用开发中,用户认证和授权是至关重要的安全功能。JSON Web Token (JWT)作为一种轻量级的认证方案,因其简单性、自包含性和跨语言支持等优点,已成为众多开发者的首选。本文将基于提供的代码和笔记,详细解析如何在一个React应用中实现JWT登录鉴权系统。

项目结构概述

首先让我们看一下项目的整体结构:

text

- src/
  - api/
    - config.js       # Axios全局配置
    - user.js         # 用户相关API
  - components/
    - NavBar/         # 导航栏组件
    - RequireAuth/    # 路由守卫组件
  - store/
    - user.js        # 用户状态管理(Zustand)
  - views/
    - Home/          # 首页
    - Login/         # 登录页
    - Pay/          # 需要认证的支付页
- App.jsx            # 主应用组件

JWT基础知识

什么是JWT

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地传输信息作为JSON对象。它由三部分组成:

  1. Header - 包含令牌类型和使用的哈希算法
  2. Payload - 包含声明(claims),即用户数据和其他元数据
  3. Signature - 用于验证消息在传输过程中未被更改

JWT工作流程

  1. 用户提交登录凭证(用户名/密码)
  2. 服务器验证凭证,生成JWT并返回给客户端
  3. 客户端存储JWT(通常使用localStorage)
  4. 后续请求在Authorization头中携带JWT
  5. 服务器验证JWT并处理请求

代码实现解析

1. 用户状态管理 (store/user.js)

import { create } from 'zustand'
import { doLogin } from '../api/user'

export const useUserStore = create((set) => ({
    user: null, // 用户信息
    isLogin: false, // 是否登录
    login: async ({ username = "", password = "" }) => {
        const data = await doLogin({ username, password })
        console.log(data)
        const { token, data: user } = data.data
        console.log(token, user);
        localStorage.setItem('token', token)
        set({
            isLogin: true,
            user,
        })
    },
    logout: () => {
        localStorage.removeItem('token')
        set({
            isLogin: false,
            user: null,
        })
    },
    setUser: (user) => set({ user }),
}))

功能解析

  1. 使用Zustand创建全局状态管理

  2. 维护userisLogin两个核心状态

  3. login方法:

    • 调用登录API
    • 将返回的token存入localStorage
    • 更新全局状态
  4. logout方法:

    • 移除localStorage中的token
    • 重置全局状态

设计考虑

  • 将认证状态集中管理,便于全局访问
  • 封装登录/登出逻辑,保持一致性
  • 使用localStorage持久化token,避免页面刷新后状态丢失

2. Axios全局配置 (api/config.js)

import axios from 'axios'
axios.defaults.baseURL = 'http://localhost:5173/api'

// 拦截器
axios.interceptors.request.use((config) => {
    const token = localStorage.getItem('token') || ""
    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }
    return config
})

axios.interceptors.response.use((res) => {
    console.log('////')
    return res
})

export default axios

功能解析

  1. 设置基础API地址

  2. 当我们后续请求当中,我们如何在Authorization头中携带JWT,使用请求拦截器:

    • 从localStorage获取token
    • 如果存在token,自动添加到请求头的Authorization字段
  3. 响应拦截器(当前仅做简单日志记录)

设计考虑

  • 自动附加认证信息,避免手动设置
  • 统一管理API配置,便于维护
  • 遵循JWT最佳实践,使用Bearer token (Bearer 其实就是说明我持有这个token)

3. API服务封装 (api/user.js)

import axios from './config'

export const getUser = () => {
    return axios.get('/user')
}

export const doLogin = (data) => {
    return axios.post('/login', data)
}

功能解析

  1. 封装用户相关API
  2. doLogin - 处理登录请求
  3. getUser - 获取用户信息(当前未使用)

设计考虑

  • 集中API定义,便于管理和重用
  • 基于配置好的axios实例,确保一致的行为

4. 路由守卫组件 (components/RequireAuth/index.jsx)

import { useUserStore } from '../../store/user'
import { useNavigate, useLocation } from 'react-router-dom'
import { useEffect } from 'react'

const RequireAuth = ({ children }) => {
    const { isLogin } = useUserStore()
    const navigate = useNavigate()
    const { pathname } = useLocation()
    useEffect(() => {
        if (!isLogin) {
            navigate('/login', { from: pathname })
        }
    }, [])
    return (
        <>
            {children}
        </>
    )
}

export default RequireAuth

功能解析

  1. 检查用户是否登录
  2. 如果未登录,重定向到登录页并记录原始路径
  3. 如果已登录,渲染子组件

设计考虑

  • 保护需要认证的路由
  • 提供友好的重定向体验,登录后可返回原页面
  • 使用React Router的hooks实现导航

5. 导航栏组件 (components/NavBar/index.jsx)

import { Link } from 'react-router-dom'
import { useUserStore } from '../../store/user'
const NavBar = () => {
    const { isLogin, user, logout } = useUserStore()
    console.log(isLogin, user, '////')
    return (
        <nav style={{ padding: '10px', borderBottom: '1px solid #ccc' }}>
            <Link to="/">Home</Link>&nbsp;&nbsp;
            <Link to="/pay">Pay</Link>&nbsp;&nbsp;
            {
                isLogin ? (
                    <>
                        <span>Welcome, {user.username}</span>&nbsp;&nbsp;
                        <button onClick={logout}>Logout</button>
                    </>
                ) : (
                    <Link to="/login">Login</Link>
                )
            }
        </nav>
    )
}

export default NavBar

功能解析

  1. 显示全局导航

  2. 根据登录状态显示不同内容:

    • 已登录:显示欢迎信息和登出按钮
    image.png
    • 未登录:显示登录链接
image.png

设计考虑

  • 提供一致的导航体验
  • 直观反映认证状态
  • 方便的登出功能

6. 登录页面 (views/Login/index.jsx)

import { useRef } from 'react'
import { useUserStore } from '../../store/user'
import { useNavigate } from 'react-router-dom'
const Login = () => {
    const usernameRef = useRef(null)
    const passwordRef = useRef(null)
    const { login } = useUserStore()
    const navigate = useNavigate()
    const handleSubmit = (e) => {
        e.preventDefault()
        const username = usernameRef.current.value
        const password = passwordRef.current.value
        if (!username || !password) {
            alert('请输入用户名和密码')
            return
        }
        login({ username, password })
            .then(res => {
                setTimeout(() => {
                    navigate('/')
                }, 1000)
            }).catch(err => {
                alert(err.message)
            })
    }
    return (
        <>
            <form onSubmit={handleSubmit}>
                <div>
                    <label htmlFor="username">Username</label>
                    <input id="username" ref={usernameRef} type="text" required placeholder="请输入用户名" />
                </div>
                <div>
                    <label htmlFor="password">Password</label>
                    <input id="password" ref={passwordRef} type="password" required placeholder="请输入密码" />
                </div>
                <div>
                    <button type="submit">Login</button>
                </div>
            </form>
        </>
    )
}

export default Login

功能解析

  1. 提供登录表单

  2. 处理表单提交:

    • 验证输入
    • 调用全局store的login方法
    • 登录成功后导航到首页
    • 处理错误情况

设计考虑

  • 使用非受控组件简化代码

我们使用非受控组件性能会好一点,受控组件可以实时获取到数据,但是性能会差一点

  • 集成全局状态管理
  • 基本的表单验证
  • 用户友好的反馈机制

7.主应用组件App.jsx

import {
  useState,
  useEffect,
  lazy,
  Suspense
} from 'react'
import './App.css'
import {
  Routes,
  Route
} from 'react-router-dom'
import NavBar from './components/NavBar'
const Home = lazy(() => import('./views/Home'))
const Login = lazy(() => import('./views/Login'))
const Pay = lazy(() => import('./views/Pay'))
const RequireAuth = lazy(() => import('./components/RequireAuth'))

function App() {

  return (
    <>
      <NavBar />
      <Suspense fallback={<div>loading</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />
          <Route path="/pay" element={
            <RequireAuth>
              <Pay />
            </RequireAuth>
          } />
        </Routes>
      </Suspense>
    </>
  )
}

export default App

8.入口文件main.jsx

import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { BrowserRouter as Router } from 'react-router-dom'

createRoot(document.getElementById('root')).render(
  <Router>
    <App />
  </Router>
)

9.Mock文件login.js

import jwt from "jsonwebtoken";
// 安全性 编码的时候用于加密
// 解码的时候用于解密
// 加盐
const secret = '!@#$%^&*'

// login 模块 mock
export default [
    {
        url: '/api/login',
        method: 'post',
        timeout: 2000,//手动指定请求耗时
        response: (req, res) => {
            // req,username,password
            const { username, password } = req.body
            if (username !== 'admin' || password !== '123456') {
                return {
                    code: 1,
                    message: '用户名或密码错误',
                }
            }
            // JSON 用户数据对象
            const token = jwt.sign({
                user: {
                    id: "001",
                    username: "admin",
                }
            }, secret, {
                expiresIn: '24h',
            })
            // console.log(token, '///')

            // 生成token 颁发令牌
            return {
                token,
                data: {
                    id: "001",
                    username: "admin",
                }
            }
        },
    },
    {
        url: '/api/user',
        method: 'get',
        response: (req, res) => {
            // 用户端 token headers
            const token = req.headers["authorization"].split(' ')[1]
            console.log(token)
            try {
                const decode = jwt.decode(token, secret)
                console.log(decode)
                return {
                    code: 0,
                    data: decode.user
                }
            } catch (err) {
                return {
                    code: 1,
                    message: 'Invalid token'
                }
            }
            return {
                token
            }
        }
    }
]

功能解析

1. 登录功能 (/api/login POST)
  • 验证凭证:检查用户名是否为"admin",密码是否为"123456"

  • 错误处理:如果验证失败,返回错误码和消息

  • JWT生成:验证成功后,使用jsonwebtoken库生成JWT令牌

    • 令牌包含用户信息(id和username)
    • 使用预设的secret进行签名
    • 设置过期时间为24小时
  • 响应:返回生成的token和用户数据

2. 用户信息获取 (/api/user GET)
  • 令牌验证:从请求头的Authorization字段中提取JWT

  • 令牌解码:使用相同的secret验证并解码JWT

  • 响应

    • 成功时返回用户信息
    • 失败时返回错误信息

JWT认证流程详解

结合上述组件,让我们梳理完整的JWT认证流程:

  1. 用户访问登录页面

    • 输入凭据并提交表单
    • 触发handleSubmit函数
  2. 登录请求处理

    • 调用useUserStorelogin方法
    • login方法通过doLoginAPI发送请求
  3. 服务器响应

    • 验证凭据
    • 生成JWT(包含用户信息)
    • 返回token和用户数据
  4. 客户端处理响应

    • 存储token到localStorage
    • 更新全局状态(isLoginuser)
  5. 后续认证请求

    • Axios拦截器自动附加Authorization
    • 格式:Bearer <token>
  6. 访问受保护路由

    • RequireAuth组件检查登录状态
    • 未登录则重定向到登录页
  7. 用户登出

    • 清除localStorage中的token
    • 重置全局状态

Vite2B20-20Chrome2019-10-47_converted.gif

总结

本文详细解析了基于JWT的前端认证系统实现。通过Zustand管理全局状态,Axios拦截器处理认证头,React Router守卫保护路由,我们构建了一个完整的前端认证流程。虽然当前实现满足基本需求,但在生产环境中还需要考虑更多安全因素和用户体验优化。

JWT作为一种现代化的认证方案,其简洁性和灵活性使其成为众多开发者的选择。理解其工作原理和实现细节,有助于我们构建更安全、更可靠的Web应用。