feat(iter-v2): T01 complete - security + infrastructure
- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore - S3: TOKEN_SECRET centralized to config/token.ts, all files import from it - S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup - S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token) - S5: POST /auth/refresh endpoint with rotation and max 5 per user - S5: Frontend request.ts auto-refresh on 40101, concurrent-safe - S6: Soft delete (deleted_at column), admin soft-delete with confirmName - S6: POST /admin/users/:id/restore endpoint - S6: All user queries filter deleted_at IS NULL - S7: DB connection requires env vars, no fallback credentials - Infra: jest.config.js, vitest.config.ts, .env.test - Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
This commit is contained in:
1209
client/package-lock.json
generated
1209
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build -p h5",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"lint": "eslint src --ext .ts,.vue"
|
||||
"lint": "eslint src --ext .ts,.vue",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
|
||||
@@ -25,12 +26,15 @@
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
|
||||
"@pinia/testing": "^0.1.7",
|
||||
"@types/node": "^20.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^8.56.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { request } from '@/utils/request'
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
@@ -18,3 +20,12 @@ export function demoLogin() {
|
||||
export function wxLogin(code: string) {
|
||||
return request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code } })
|
||||
}
|
||||
|
||||
/** 刷新 Access Token */
|
||||
export function refreshTokenApi(token: string) {
|
||||
return request<{ accessToken: string; refreshToken: string; expiresIn: number }>({
|
||||
url: '/auth/refresh',
|
||||
method: 'POST',
|
||||
data: { refreshToken: token }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,20 +8,85 @@ interface RequestOptions {
|
||||
data?: any
|
||||
}
|
||||
|
||||
let isRedirectingToLogin = false
|
||||
/** 重登录期间挂起的请求,登录成功后统一重试 */
|
||||
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
|
||||
/** 是否正在刷新 token(并发控制:只允许一次刷新) */
|
||||
let isRefreshing = false
|
||||
/** 刷新期间挂起的请求,刷新成功后统一重试 */
|
||||
let pendingRequests: Array<{
|
||||
resolve: (v: any) => void
|
||||
reject: (e: any) => void
|
||||
options: RequestOptions
|
||||
}> = []
|
||||
|
||||
/** 保存登录结果到本地存储 */
|
||||
export function saveLoginResult(data: LoginResult) {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
/** 保存登录结果到本地存储(兼容新旧 token 格式) */
|
||||
export function saveLoginResult(data: any) {
|
||||
// 向后兼容:旧版返回 token 字段映射到 accessToken
|
||||
const accessToken = data.accessToken || data.token || ''
|
||||
const refreshToken = data.refreshToken || ''
|
||||
const expiresIn = data.expiresIn || 7200
|
||||
|
||||
uni.setStorageSync('xc:token', accessToken)
|
||||
uni.setStorageSync('xc:refreshToken', refreshToken)
|
||||
uni.setStorageSync('xc:tokenExpiresIn', expiresIn)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
uni.setStorageSync('xc:nickname', data.nickname)
|
||||
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
||||
if (data.role) uni.setStorageSync('xc:role', data.role)
|
||||
}
|
||||
|
||||
/** 执行重登录 */
|
||||
/** 清除本地登录信息 */
|
||||
function clearLoginInfo() {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:refreshToken')
|
||||
uni.removeStorageSync('xc:tokenExpiresIn')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
uni.removeStorageSync('xc:nickname')
|
||||
uni.removeStorageSync('xc:avatar_url')
|
||||
uni.removeStorageSync('xc:role')
|
||||
uni.removeStorageSync('xc:currentGroupId')
|
||||
}
|
||||
|
||||
/** 跳转到登录页 */
|
||||
function redirectToLogin() {
|
||||
clearLoginInfo()
|
||||
// H5 环境刷新页面重新走登录流程
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新 token:用 refreshToken 换取新的双 token */
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const storedRefreshToken = uni.getStorageSync('xc:refreshToken')
|
||||
if (!storedRefreshToken) return null
|
||||
|
||||
try {
|
||||
// 直接用 uni.request 发起,避免走 request 函数导致循环
|
||||
const res: any = await new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: API_BASE + '/auth/refresh',
|
||||
method: 'POST',
|
||||
data: { refreshToken: storedRefreshToken },
|
||||
timeout: 10000,
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
|
||||
if (res.statusCode === 200 && res.data?.code === 0) {
|
||||
const { accessToken, refreshToken: newRefreshToken, expiresIn } = res.data.data
|
||||
uni.setStorageSync('xc:token', accessToken)
|
||||
uni.setStorageSync('xc:refreshToken', newRefreshToken)
|
||||
uni.setStorageSync('xc:tokenExpiresIn', expiresIn || 7200)
|
||||
return accessToken
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行重登录(没有 refreshToken 或刷新失败时的兜底) */
|
||||
async function reLogin(): Promise<void> {
|
||||
// H5 环境:demo 登录
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -56,34 +121,47 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
const { statusCode, data } = res
|
||||
|
||||
if (statusCode === 401) {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
uni.removeStorageSync('xc:nickname')
|
||||
uni.removeStorageSync('xc:avatar_url')
|
||||
uni.removeStorageSync('xc:role')
|
||||
uni.removeStorageSync('xc:currentGroupId')
|
||||
const errorCode = data?.code
|
||||
|
||||
// 加入重试队列
|
||||
pendingRetries.push({ resolve, reject, options })
|
||||
// code 40101 = token 过期 → 尝试用 refreshToken 刷新
|
||||
if (errorCode === 40101) {
|
||||
// 加入等待队列
|
||||
pendingRequests.push({ resolve, reject, options })
|
||||
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
|
||||
reLogin()
|
||||
.then(() => {
|
||||
// 处理快照时已入队的 + reLogin 期间新入队的请求
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
})
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||
})
|
||||
.finally(() => { isRedirectingToLogin = false })
|
||||
refreshAccessToken()
|
||||
.then((newToken) => {
|
||||
if (newToken) {
|
||||
// 刷新成功:重试所有等待中的请求
|
||||
const retries = [...pendingRequests]
|
||||
pendingRequests = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
} else {
|
||||
// 刷新失败:尝试完整重登录
|
||||
return reLogin().then(() => {
|
||||
const retries = [...pendingRequests]
|
||||
pendingRequests = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 重登录也失败
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
const retries = [...pendingRequests]
|
||||
pendingRequests = []
|
||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||
})
|
||||
.finally(() => { isRefreshing = false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// code 40100 = token 无效/未登录 → 直接清 token 跳登录
|
||||
redirectToLogin()
|
||||
reject({ code: 40100, message: data?.message || '未登录' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
14
client/vitest.config.ts
Normal file
14
client/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
581
client/yarn.lock
581
client/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user