文章目录
1 JWT
JSON Web Token(JWT)是一种开放标准(RFC 7519),用于在各方之间安全传输信息。它通过数字签名确保数据的完整性和可信性,常用于身份验证和授权。以下是JWT的详细介绍:
1.1 JWT结构
JWT由三部分组成,用点(.
)分隔:
- Header(头部)
包含令牌类型(typ: "JWT"
)和签名算法(如alg: HS256
)。
示例:{"alg": "HS256", "typ": "JWT"}
→ Base64Url编码后形成第一部分。 - Payload(载荷)
存放声明(claims),包括预定义声明(如用户ID、过期时间)和自定义数据。
常见预定义声明:iss
(签发者)、exp
(过期时间)、sub
(主题)、aud
(受众)等。
示例:{"sub": "123", "name": "Alice", "exp": 1516239022}
→ Base64Url编码后形成第二部分。
- Signature(签名)
对前两部分的签名,防止数据篡改。算法由Header指定(如HMAC SHA256)。
生成方式:HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
。
最终JWT形式:xxxxx.yyyyy.zzzzz
。
1.2 工作流程
- 用户登录:客户端发送凭证(如用户名/密码)到服务器。
- 生成JWT:服务器验证凭证,生成并返回JWT。
- 客户端存储:客户端保存JWT(通常存在
localStorage
或Cookie中)。 - 携带令牌请求:客户端在请求头中添加
Authorization: Bearer <JWT>
。 - 服务器验证:服务器检查签名有效性、过期时间等,验证通过后处理请求。
1.3 优点
- 无状态:无需服务器存储会话信息,适合分布式系统。
- 跨域支持:适用于API网关、单页应用(SPA)等场景。
- 灵活性:载荷可自定义扩展,传递非敏感用户信息。
1.4 缺点
- 不可废止:令牌到期前无法强制失效,需借助黑名单或短过期时间。
- 存储风险:客户端存储不当可能导致XSS攻击窃取令牌。
- 信息暴露:载荷仅Base64编码,需避免存放敏感数据。
1.5 安全实践
- 使用HTTPS:防止令牌在传输中被截获。
- 强签名算法:如HMAC SHA256或RSA,避免弱算法(如HS256密钥过短)。
- 合理设置过期时间:缩短令牌有效期,减少泄露风险。
- 敏感数据加密:必要时使用JWE(JSON Web Encryption)加密载荷。
1.6. 适用场景
- API认证:RESTful API的无状态身份验证。
- 单点登录(SSO):跨多个系统的用户身份共享。
- 移动端应用:减少频繁查询数据库的开销。
1.7 JWT与OAuth2
- JWT常用作OAuth2的Bearer Token,传递用户身份和权限。
- OAuth2定义授权流程,JWT是实现令牌的一种方式。
8. 示例代码(Node.js)
const jwt = require('jsonwebtoken');
// 生成JWT
const token = jwt.sign(
{ userId: 123, role: 'admin' },
'your-secret-key',
{ expiresIn: '1h' }
);
// 验证JWT
jwt.verify(token, 'your-secret-key', (err, decoded) => {
if (err) throw err;
console.log(decoded); // { userId: 123, role: 'admin', iat: ..., exp: ... }
});
通过理解JWT的结构、流程及安全实践,开发者可以有效利用其在现代Web应用中实现安全、高效的身份验证。
2 用户mock和api
用户mock,user.js代码如下所示:
const Mock = require('mockjs')
const Random = Mock.Random
module.exports = [
{
// 获取用户
url: '/api/user/info',
method: 'get',
response() {
return {
errno: 0,
data: {
username: Random.title(),
nickname: Random.cname(),
},
}
}
},
{
// 注册新用户
url: '/api/user/register',
method: 'post',
response() {
return {
errno: 0
}
}
},
{
// 用户登录
url: '/api/user/login',
method: 'post',
response() {
return {
errno: 0,
data: {
token: Random.word(20)
},
}
}
},
]
前端user.ts 用户api接口代码如下所示:
import request, { ResDataType } from "../services/request";
/**
* 获取用户信息
* @returns 用户信息
*/
export async function getUserInfoApi(): Promise<ResDataType> {
const url = "/api/user/info";
const data = (await request.get(url)) as ResDataType;
return data;
}
/**
* 注册新用户
* @returns 注册是否成功
*/
export async function registerApi(
username: string,
password: string,
nickname?: string
): Promise<ResDataType> {
const url = "/api/user/register";
const body = { username, password, nickname: nickname || username };
const data = (await request.post(url, body)) as ResDataType;
return data;
}
/**
* 用户登录
* @returns token
*/
export async function loginApi(
username: string,
password: string
): Promise<ResDataType> {
const url = "/api/user/login";
const data = (await request.post(url, { username, password })) as ResDataType;
return data;
}
3 注册
Register.tsx代码如下所示:
import { FC } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Typography, Space, Form, Input, Button, message } from "antd";
import { UserAddOutlined } from "@ant-design/icons";
import { useRequest } from "ahooks";
import { LOGIN_PATHNAME } from "../router";
import { registerApi } from "@/api/user";
import styles from "./Register.module.scss";
const { Title } = Typography;
const Register: FC = () => {
const nav = useNavigate();
const { run: handleRegister } = useRequest(
async (values) => {
const { username, password, nickname } = values;
return await registerApi(username, password, nickname);
},
{
manual: true,
onSuccess() {
message.success("注册成功");
// 跳转登录页
nav(LOGIN_PATHNAME);
},
}
);
function onFinish(values: any) {
handleRegister(values);
}
return (
<div className={styles.container}>
<div>
<Space>
<Title level={2}>
<UserAddOutlined />
</Title>
<Title level={2}>注册新用户</Title>
</Space>
</div>
<div>
<Form
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
onFinish={onFinish}
>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: "请输入用户名" },
{
type: "string",
min: 5,
max: 20,
message: "字符长度再5-20之间",
},
{
pattern: /^\w+$/,
message: "只能是字母数字下划线",
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: "请输入用户名" },
{
min: 8,
message: "密码长度最少8位",
},
]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="确认密码"
name="confirm"
dependencies={["password"]}
rules={[
{
required: true,
message: "请输入确认密码",
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
} else {
return Promise.reject(new Error("两次密码不一致"));
}
},
}),
]}
>
<Input.Password />
</Form.Item>
<Form.Item label="昵称" name="nickname">
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 16 }}>
<Space>
<Button type="primary" htmlType="submit">
注册
</Button>
<Link to={LOGIN_PATHNAME}>已有账户,登录</Link>
</Space>
</Form.Item>
</Form>
</div>
</div>
);
};
export default Register;
执行注册,成功挑战登录页,如下图所示:
4 登录
登录页Login.tsx代码如下所示:
import { FC, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Typography, Space, Form, Input, Button, Checkbox, message } from "antd";
import { UserAddOutlined } from "@ant-design/icons";
import { useRequest } from "ahooks";
import { MANAGE_INDEX_PATHNAME, REGISTER_PATHNAME } from "../router";
import { loginApi } from "@/api/user";
import styles from "./Register.module.scss";
const { Title } = Typography;
const USERNAME_KEY = "username";
const PASSWORD_KEY = "password";
/**
* 浏览器本地存储用户信息
* @param username 用户名
* @param password 密码
*/
function rememberUser(username: string, password: string) {
localStorage.setItem(USERNAME_KEY, username);
localStorage.setItem(PASSWORD_KEY, password);
}
/**
* 浏览器本地删除用户信息
* @param username 用户名
* @param password 密码
*/
function deleteUserFromStorage(username: string, password: string) {
localStorage.removeItem(USERNAME_KEY);
localStorage.removeItem(PASSWORD_KEY);
}
/**
* 浏览器本地获取用户信息
*/
function getUserInfoFromStorage() {
return {
username: localStorage.getItem(USERNAME_KEY),
password: localStorage.getItem(PASSWORD_KEY),
};
}
const Login: FC = () => {
const nav = useNavigate()
// 表单组件初始化
const [form] = Form.useForm();
useEffect(() => {
const { username, password } = getUserInfoFromStorage();
form.setFieldsValue({ username, password });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { run: handleLogin } = useRequest(
async (values) => {
const { username, password } = values;
return await loginApi(username, password);
},
{
manual: true,
onSuccess(res) {
message.success("登录成功")
// todo 存储token
// 跳转我的问卷
nav(MANAGE_INDEX_PATHNAME)
},
}
);
function onFinish(values: any) {
const { username, password, remember } = values || {};
if (remember) {
rememberUser(username, password);
} else {
deleteUserFromStorage(username, password);
}
handleLogin({ username, password });
}
return (
<div className={styles.container}>
<div>
<Space>
<Title level={2}>
<UserAddOutlined />
</Title>
<Title level={2}>用户登录</Title>
</Space>
</div>
<div>
<Form
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
onFinish={onFinish}
initialValues={{ remember: true }}
form={form}
>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: "请输入用户名" },
{
type: "string",
min: 5,
max: 20,
message: "字符长度再5-20之间",
},
{
pattern: /^\w+$/,
message: "只能是字母数字下划线",
},
]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: "请输入用户名" },
{
min: 8,
message: "密码长度最少8位",
},
]}
>
<Input.Password />
</Form.Item>
<Form.Item
wrapperCol={{ offset: 6, span: 16 }}
name="remember"
valuePropName="checked"
>
<Checkbox>记住我</Checkbox>
</Form.Item>
<Form.Item wrapperCol={{ offset: 6, span: 16 }}>
<Space>
<Button type="primary" htmlType="submit">
登录
</Button>
<Link to={REGISTER_PATHNAME}>注册新用户</Link>
</Space>
</Form.Item>
</Form>
</div>
</div>
);
};
export default Login;
登录成功后跳转我的问卷也,如下图所示:
5 token存储
用户登录成功后,需要存储token,userToken.ts代码如下所示
/**
* @description localStorage管理用户token
* @author gaogzhen
*/
const KEY = "USER-TOKEN"
/**
* 设置token
* @param token
*/
export function setToken(token:string) {
localStorage.setItem(KEY, token)
}
/**
* 获取token
* @returns token
*/
export function getToken() {
return localStorage.getItem(KEY) || ''
}
/**
* 删除token
*/
export function removeToken() {
localStorage.removeItem(KEY)
}
登录页登录成功后,执行存储token,Login.tsx代码如下:
const { run: handleLogin } = useRequest(
async (values) => {
const { username, password } = values;
return await loginApi(username, password);
},
{
manual: true,
onSuccess(res) {
message.success("登录成功");
// 存储token
const { token = "" } = res;
setToken(token);
// 跳转我的问卷
nav(MANAGE_INDEX_PATHNAME);
},
}
);
localStorage存储如下图哦所示:
6 请求拦截器设置token
登录成功后,用户每次请求需要携带token,用户身份验证、权限验证等。这里通过请求拦截器实现,request.ts代码如下所示:
import axios from "axios";
import { message } from "antd";
import { AUTHORIZATION } from "@/constant";
import { getToken } from "@/utils/userToken";
const request = axios.create({
timeout: 5000,
});
// request拦截:每次请求携带token
request.interceptors.request.use((config) => {
// todo token 校验
config.headers[AUTHORIZATION] = `Bearer ${getToken()}`;
return config;
});
// response 拦截:统一处理errno和msg
request.interceptors.response.use((res) => {
const resData = (res.data || {}) as ResType;
const { errno, data, msg } = resData;
if (errno !== 0) {
// 错误提示
if (msg) {
message.error(msg);
}
throw new Error(msg);
}
return data as any;
});
export default request;
export type ResDataType = {
[key: string]: any;
};
export type ResType = {
errno: number;
data?: ResDataType;
msg?: string;
};
效果如下图所示:
6 获取用户信息
用户登录之后,用户信息很多地方需要使用,在学习状态管理之后再处理,这里我们暂时在用户信息组件处理。
用户信息UserInfo.tsx代码如下所示:
import { FC } from "react";
import { Link } from "react-router-dom";
import { LOGIN_PATHNAME } from "../router/index";
import { useRequest } from "ahooks";
import { getUserInfoApi } from "@/api/user";
import { UserOutlined } from "@ant-design/icons";
import { Button } from "antd";
const UserInfo: FC = () => {
const { data } = useRequest(getUserInfoApi);
const { username, nickname } = data || {};
const User = (
<>
<span style={{ color: "#e8e8e8" }}>
<UserOutlined />
{nickname}
</span>
<Button type="link">退出</Button>
</>
);
const Login = <Link to={LOGIN_PATHNAME}>登录</Link>;
return <>{username ? User : Login}</>;
};
export default UserInfo;
效果如下图所示:
7 退出登录
UserInfo.tsx退出功能代码如下所示:
import { FC } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useRequest } from "ahooks";
import { Button } from "antd";
import { UserOutlined } from "@ant-design/icons";
import { LOGIN_PATHNAME } from "../router/index";
import { getUserInfoApi } from "@/api/user";
import { removeToken } from "@/utils/userToken";
const UserInfo: FC = () => {
const nav = useNavigate()
const { data } = useRequest(getUserInfoApi);
const { username, nickname } = data || {};
function logout() {
removeToken()
// 跳转登录页
nav(LOGIN_PATHNAME)
}
const User = (
<>
<span style={{ color: "#e8e8e8" }}>
<UserOutlined />
{nickname}
</span>
<Button type="link" onClick={logout}>退出</Button>
</>
);
const Login = <Link to={LOGIN_PATHNAME}>登录</Link>;
return <>{username ? User : Login}</>;
};
export default UserInfo;
注:
- 执行退出,但是右上角还是显示登录状态,后面处理
结语
❓QQ:806797785
⭐️仓库地址:https://gitee.com/gaogzhen
⭐️仓库地址:https://github.com/gaogzhen
[1]ahook官网[CP/OL].
[2]mock文档[CP/OL].
[3]Ant Design官网[CP/OL].