项目-小红书开发技术文档
模块设计
登录模块
登录令牌
介绍
Access Token 作为用户已登录的令牌(时间较短,一般来说 15-30 分钟)
Refresh Token 作为刷新令牌的密钥(时间长,15-30 天,如果过期了,要求用户重新登录)
❓为什么要这么做,一个 Access Token 不行吗?
如果只是一个 Access Token,当别人拿到 Access Token,就会模拟用户请求,十分的不安全(XSS 攻击)
加上 Refresh Token 后,哪怕 Access Token 被盗走了,也只能使用很短一段时间,之后就会失效,这样增加了安全性
同时将 Refresh Token 放到 HttpOnly Cookie 中,防止使用 js 恶意脚本读取,并且如果有恶意用户,我们只需要在 数据库 中修改用户的 Refresh Token 状态,那么这个用户就会被踢出,无法登录
❓既然这个这么 HttpOnly Cookie 这么安全,为什么不直接把 Access Token 放到 HttpOnly Cookie 中?
CSRF 风险:
如果
Access Token在 HttpOnly Cookie 里,浏览器会 自动带上它(和refresh_token一样)。
那么任何第三方网站只要诱导用户点击一个请求(CSRF 攻击),这个请求就会带上
Access Token,后端就认为是合法的用户请求。结果:攻击者可能发起恶意请求(比如转账、删帖),用户毫不知情。
👉 所以,
Access Token更适合放在 Authorization Header,因为它不会被浏览器自动附加,必须前端代码显式添加。这样能防御 CSRF。短时效 vs 长时效
Access Token 通常生命周期短(15–30 分钟),主要目的是减少风险暴露。
Refresh Token 生命周期长(7–30 天),所以必须 HttpOnly Cookie + 入库校验。
如果你把 Access Token 也放 Cookie,其实就跟 Refresh Token 没啥区别了,失去了它作为「轻量、短时效凭证」的意义。
登录流程为:
用户输入账号密码 -> 后端比对账号密码 -> 成功了 -> 派发 Access Token 和 Refresh Token -> Refresh Token存入数据库,并将 Refresh Token 写入 HttpOnly Cookie(js 无法读取,防止 XSS 攻击),将 Access Token 返回前端
前端收到 Access Token 后,存到 localStorage 中,之后的请求携带上这个
如果请求出现 401 了,则需要前端重新请求刷新 Access Token
代码
登录成功后:
生成
access_token和refresh_tokenrefresh_token通过Set-Cookie写到响应头里(右侧代码)access_token返回在响应体里,前端拿到后放 Pinia / localStorage
c.SetCookie( "refresh_token", refreshToken, 7*24*3600, // 有效期 7 天 "/", // 路径 "yourdomain.com", // domain true, // secure(生产必须 true,只能 https 下用) true, // httpOnly(防止 JS 获取) )前端登录成功后:
拿到
access_token存到 Pinia(或 localStorage)
// store/auth.ts import { defineStore } from 'pinia' export const useAuthStore = defineStore('auth', { state: () => ({ accessToken: '' as string | null }), actions: { setAccessToken(token: string) { this.accessToken = token }, clear() { this.accessToken = null } } })前端请求接口时:
1.
access_token放到请求头里:// plugins/axios.ts import axios from 'axios' import { useAuthStore } from '~/store/auth' export default defineNuxtPlugin(() => { const auth = useAuthStore() const api = axios.create({ baseURL: '/api', withCredentials: true // 必须开,否则 Cookie 不会带上 }) // 请求拦截器 api.interceptors.response.use( response => response, async error => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { if (!isRefreshing) { isRefreshing = true; try { const res = await api.post("/auth/refresh"); const newToken = res.data.accessToken; // 更新内存里的 token window.localStorage.setItem("accessToken", newToken); isRefreshing = false; onRefreshed(newToken); } catch (err) { isRefreshing = false; // 刷新失败,跳转登录 window.location.href = "/login"; return Promise.reject(err); } } return new Promise(resolve => { subscribeTokenRefresh((newToken: string) => { originalRequest._retry = true; originalRequest.headers["Authorization"] = `Bearer ${newToken}`; resolve(api(originalRequest)); }); }); } return Promise.reject(error); } );后端刷新接口:
从 Cookie 里取
refresh_token查数据库校验
如果合法 → 签发新
access_token并返回
func RefreshHandler(c *gin.Context) { refreshToken, err := c.Cookie("refresh_token") if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "No refresh token"}) return } // 验证 refreshToken 合法性(查数据库) userID, err := VerifyRefreshToken(refreshToken) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"}) return } // 签发新的 accessToken newAccessToken, _ := GenerateAccessToken(userID) c.JSON(http.StatusOK, gin.H{ "access_token": newAccessToken, }) }
路由鉴权
前端:
创建中间件
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const authStore = useAuthStore()
if (!authStore.accessToken) {
return navigateTo('/login')
}
})在需要使用的页面:
// index.vue
definePageMeta({
middleware: 'auth'
})后端:
后端也需要对需要用户登录的接口进行验证
浏览器指纹(设备码)
在浏览器中很难实现唯一设备码,所以只能模拟实现:
使用第三方库 FingerprintJS.js 生成设备码,但是可能不是唯一的,如用户清除缓存或者使用隐身模式可能会发生变化
import FingerprintJS from '@fingerprintjs/fingerprintjs'
const fp = await FingerprintJS.load()
const result = await fp.get()
const deviceId = result.visitorId // 生成的唯一 ID数据库表设计
用户信息
Table:user 用户基础信息表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
fuzzy_user_id VARCHAR(255) NOT NULL,
user_name VARCHAR(255) NOT NULL UNIQUE,
nick_name VARCHAR(255) DEFAULT '',
avatar VARCHAR(255) DEFAULT '',
email VARCHAR(255) DEFAULT '',
phone INT DEFAULT 0,
password VARCHAR(255) DEFAULT '',
bio TEXT,
user_type TINYINT UNSIGNED DEFAULT 1,
gender TINYINT UNSIGNED DEFAULT 0 COMMENT '0未知,1男,2女',
birthday DATETIME DEFAULT NULL,
country VARCHAR(255) DEFAULT '',
country_code INT DEFAULT 0,
language VARCHAR(255) DEFAULT '',
device_id VARCHAR(255) DEFAULT '',
status TINYINT UNSIGNED DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 创建索引
CREATE INDEX idx_users_id ON users(id);
CREATE INDEX idx_users_fuzzy_user_id ON users(fuzzy_user_id);
CREATE INDEX idx_users_username ON users(user_name);
Table:userextension 用户扩展信息表
CREATE TABLE user_extensions (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
last_login_at DATETIME DEFAULT NULL,
last_active_at DATETIME DEFAULT NULL,
reset_password_at DATETIME DEFAULT NULL,
item_likes INT DEFAULT 0 COMMENT '我的帖子被喜欢数',
item_dislikes INT DEFAULT 0 COMMENT '我的帖子被不喜欢数量',
item_shares INT DEFAULT 0 COMMENT '我的帖子被分享量',
item_count INT DEFAULT 0 COMMENT '我发布的帖子数量',
item_collects INT DEFAULT 0 COMMENT '我的帖子被收藏量',
first_item_post_at DATETIME DEFAULT NULL COMMENT '第一次发帖子时间',
last_item_post_at DATETIME DEFAULT NULL COMMENT '最后一次发帖子时间',
comment_count INT DEFAULT 0 COMMENT '我的评论数量',
dislike_count INT DEFAULT 0 COMMENT '我不喜欢的数量',
like_count INT DEFAULT 0 COMMENT '我喜欢的数量',
share_count INT DEFAULT 0 COMMENT '我分享的数量',
collect_count INT DEFAULT 0 COMMENT '我收藏的数量',
following_count INT DEFAULT 0 COMMENT '我的关注数',
follower_count INT DEFAULT 0 COMMENT '我的粉丝数',
has_shop TINYINT UNSIGNED DEFAULT 0 COMMENT '是否有店铺,0没有,1有',
interests VARCHAR(255) DEFAULT '' COMMENT '感兴趣的方向',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 外键约束
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- 创建索引
CREATE INDEX idx_user_extensions_id ON user_extensions(id);
CREATE INDEX idx_user_extensions_user_id ON user_extensions(user_id);Table:user_refresh_tokens 用户登录Token 表
Table:user_devices 用户设备表
帖子信息
Table:posts 帖子主表
Table:post_resource_groups 帖子资源分组表
存储帖子里不同类型的分组(图集 / 视频 / 混合段落):
Table:post_resources 帖子资源表
存储帖子里不同类型的分组(图集 / 视频 / 混合段落):