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:
7
server/jest.config.js
Normal file
7
server/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
}
|
||||
3923
server/package-lock.json
generated
3923
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && shx cp src/db/*.sql dist/db/",
|
||||
"start": "node dist/index.js",
|
||||
"db:init": "tsx src/db/init.ts"
|
||||
"db:init": "tsx src/db/init.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -21,7 +22,11 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"shx": "^0.4.0",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
|
||||
22
server/src/config/token.ts
Normal file
22
server/src/config/token.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Token 安全配置 — 集中管理 TOKEN_SECRET 和启动校验
|
||||
*
|
||||
* 所有需要 TOKEN_SECRET 的模块统一从此处导入,
|
||||
* 避免各文件内联定义导致的不一致风险。
|
||||
*/
|
||||
|
||||
/** 从环境变量读取 TOKEN_SECRET,无默认值(启动时强制校验) */
|
||||
export const TOKEN_SECRET = process.env.TOKEN_SECRET || ''
|
||||
|
||||
/** 启动时检查 TOKEN_SECRET 安全性,使用默认值则拒绝启动 */
|
||||
export function checkTokenSecret(): void {
|
||||
const defaults = [
|
||||
'xiaocai-token-secret-change-in-production',
|
||||
'change-me-to-a-secure-random-string',
|
||||
'',
|
||||
]
|
||||
if (defaults.includes(TOKEN_SECRET) || TOKEN_SECRET.length < 32) {
|
||||
console.error('[Security] FATAL: TOKEN_SECRET is missing, using a default, or shorter than 32 chars. Set a secure TOKEN_SECRET (min 32 chars) in .env before starting the server.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
// 必需环境变量校验
|
||||
const REQUIRED_ENV = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']
|
||||
const missing = REQUIRED_ENV.filter(key => !process.env[key])
|
||||
if (missing.length > 0) {
|
||||
console.error(`[DB] FATAL: Missing required environment variables: ${missing.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'xiaocai',
|
||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||
database: process.env.DB_NAME || 'xiaocai',
|
||||
host: process.env.DB_HOST!,
|
||||
user: process.env.DB_USER!,
|
||||
password: process.env.DB_PASSWORD!,
|
||||
database: process.env.DB_NAME!,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
|
||||
@@ -311,16 +311,48 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// users 表添加软删除字段
|
||||
const hasDeletedAt = await columnExists(conn, 'users', 'deleted_at')
|
||||
if (!hasDeletedAt) {
|
||||
console.log('[DB] Migrating: adding deleted_at to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)'")
|
||||
await conn.query('ALTER TABLE users ADD INDEX idx_users_deleted (deleted_at)')
|
||||
console.log('[DB] users.deleted_at added')
|
||||
}
|
||||
|
||||
// Refresh Token 表
|
||||
const hasRefreshTokens = await tableExists(conn, 'refresh_tokens')
|
||||
if (!hasRefreshTokens) {
|
||||
console.log('[DB] Migrating: creating refresh_tokens table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
console.log('[DB] refresh_tokens table created')
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
const dbName = process.env.DB_NAME || 'xiaocai'
|
||||
const dbName = process.env.DB_NAME!
|
||||
const dbHost = process.env.DB_HOST!
|
||||
const dbUser = process.env.DB_USER!
|
||||
const dbPassword = process.env.DB_PASSWORD!
|
||||
|
||||
// 先用不指定数据库的连接,确保数据库存在
|
||||
const bootstrap = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'xiaocai',
|
||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||
host: dbHost,
|
||||
user: dbUser,
|
||||
password: dbPassword,
|
||||
multipleStatements: true,
|
||||
})
|
||||
|
||||
@@ -329,9 +361,9 @@ export async function initDatabase() {
|
||||
|
||||
// 用独立连接执行建表和填充(需要 multipleStatements)
|
||||
const initConn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'xiaocai',
|
||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||
host: dbHost,
|
||||
user: dbUser,
|
||||
password: dbPassword,
|
||||
database: dbName,
|
||||
multipleStatements: true,
|
||||
})
|
||||
|
||||
@@ -14,3 +14,22 @@ ALTER TABLE budgets ADD FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CAS
|
||||
|
||||
-- categories 表确认 user_id 存在(旧版可能也没有)
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义' AFTER id;
|
||||
|
||||
-- v2 迭代迁移
|
||||
|
||||
-- users 表:新增软删除字段
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
|
||||
|
||||
-- 新增 refresh_tokens 表
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -6,8 +6,10 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
|
||||
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||
deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_users_deleted (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
@@ -190,3 +192,15 @@ CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -27,6 +27,7 @@ import recurringRoutes from './routes/recurring'
|
||||
import tagRoutes from './routes/tag'
|
||||
import exportRoutes from './routes/export'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
import { checkTokenSecret } from './config/token'
|
||||
|
||||
// 强制检查 TOKEN_SECRET 安全性
|
||||
checkTokenSecret()
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
import { TOKEN_SECRET } from '../config/token'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number
|
||||
}
|
||||
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
|
||||
|
||||
// 不需要认证的路径
|
||||
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/health']
|
||||
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/auth/refresh', '/api/health']
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
// 公开路径跳过认证
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AuthRequest } from './auth'
|
||||
/** 管理员权限检查中间件 */
|
||||
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||
const [rows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
|
||||
const user = (rows as any[])[0]
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
|
||||
|
||||
@@ -11,7 +11,7 @@ router.use((req, res, next) => requireAdmin(req, res, next))
|
||||
/** 数据看板 */
|
||||
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
|
||||
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL')
|
||||
const [txThisMonth] = await pool.query(
|
||||
`SELECT COUNT(*) as count,
|
||||
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
|
||||
@@ -71,13 +71,13 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
|
||||
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
|
||||
const offset = (pNum - 1) * pSize
|
||||
|
||||
let where = ''
|
||||
let where = 'WHERE u.deleted_at IS NULL'
|
||||
const params: any[] = []
|
||||
|
||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
|
||||
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||
where = 'WHERE u.nickname LIKE ?'
|
||||
where += ' AND u.nickname LIKE ?'
|
||||
params.push(`%${escapedKeyword}%`)
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
||||
// 如果要降级管理员,检查是否至少保留一个管理员
|
||||
if (role === 'user') {
|
||||
const [adminCount] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
|
||||
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?",
|
||||
[req.params.id]
|
||||
)
|
||||
if ((adminCount as any[])[0].count === 0) {
|
||||
@@ -146,7 +146,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除用户 */
|
||||
/** 软删除用户(需要二次确认用户名) */
|
||||
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 不能删除自己
|
||||
@@ -154,12 +154,27 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
||||
}
|
||||
|
||||
// 检查是否是管理员,如果是则确保至少保留一个
|
||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
|
||||
// 需要前端传 confirmName 二次确认
|
||||
const { confirmName } = req.body
|
||||
if (!confirmName) {
|
||||
return res.status(400).json({ code: 40001, message: '需要确认用户名' })
|
||||
}
|
||||
|
||||
const [userRows] = await pool.query('SELECT nickname, role FROM users WHERE id = ? AND deleted_at IS NULL', [req.params.id])
|
||||
const user = (userRows as any[])[0]
|
||||
if (user && user.role === 'admin') {
|
||||
if (!user) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
|
||||
// 确认用户名匹配
|
||||
if (user.nickname !== confirmName) {
|
||||
return res.status(400).json({ code: 40001, message: '确认用户名不匹配' })
|
||||
}
|
||||
|
||||
// 如果是管理员,确保至少保留一个
|
||||
if (user.role === 'admin') {
|
||||
const [adminCount] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
|
||||
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?",
|
||||
[req.params.id]
|
||||
)
|
||||
if ((adminCount as any[])[0].count === 0) {
|
||||
@@ -167,10 +182,12 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
// 软删除:设置 deleted_at
|
||||
await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
|
||||
|
||||
// 撤销该用户所有有效的 Refresh Token
|
||||
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL', [req.params.id])
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Admin] delete user error:', err)
|
||||
@@ -178,4 +195,18 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 恢复软删除的用户 */
|
||||
router.post('/users/:id/restore', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [result] = await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id])
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Admin] restore user error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,31 +1,65 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { createHmac } from 'crypto'
|
||||
import { createHmac, randomBytes, createHash } from 'crypto'
|
||||
import pool from '../db/connection'
|
||||
import { TOKEN_SECRET } from '../config/token'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const APPID = process.env.WX_APPID || ''
|
||||
const SECRET = process.env.WX_SECRET || ''
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
|
||||
/** 启动时检查 TOKEN_SECRET 是否为默认占位符,是则拒绝启动 */
|
||||
export function checkTokenSecret(): void {
|
||||
const defaults = [
|
||||
'xiaocai-token-secret-change-in-production',
|
||||
'change-me-to-a-secure-random-string',
|
||||
]
|
||||
if (defaults.includes(TOKEN_SECRET)) {
|
||||
console.error('[Auth] FATAL: TOKEN_SECRET is using a default placeholder. Set a secure TOKEN_SECRET in .env before starting the server.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成 HMAC 签名的 Access Token(2h 过期) */
|
||||
export function signToken(userId: number): string {
|
||||
const payload = `${userId}:${Date.now()}`
|
||||
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||
return Buffer.from(`${payload}:${signature}`).toString('base64')
|
||||
}
|
||||
|
||||
/** 生成随机 Refresh Token(64 字符十六进制) */
|
||||
function generateRefreshToken(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
/** 对 token 进行 SHA-256 哈希(存储到数据库前使用) */
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
/** 登录成功后的通用逻辑:生成双 token 并存储 Refresh Token */
|
||||
async function issueTokens(userId: number): Promise<{
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}> {
|
||||
const accessToken = signToken(userId)
|
||||
const refreshToken = generateRefreshToken()
|
||||
const tokenHash = hashToken(refreshToken)
|
||||
|
||||
// 存入 refresh_tokens 表,30 天过期
|
||||
await pool.query(
|
||||
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||
[userId, tokenHash]
|
||||
)
|
||||
|
||||
// 检查该用户有效 refresh_tokens 数量,超过 5 个则撤销最旧的
|
||||
const [tokenRows] = await pool.query(
|
||||
'SELECT id FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW() ORDER BY created_at ASC',
|
||||
[userId]
|
||||
)
|
||||
const tokens = tokenRows as any[]
|
||||
if (tokens.length > 5) {
|
||||
const revokeIds = tokens.slice(0, tokens.length - 5).map((t: any) => t.id)
|
||||
if (revokeIds.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id IN (${revokeIds.map(() => '?').join(',')})`,
|
||||
revokeIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { accessToken, refreshToken, expiresIn: 7200 }
|
||||
}
|
||||
|
||||
// H5 演示登录 — 创建/复用演示用户
|
||||
router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -39,10 +73,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||
// 首个用户自动设为管理员
|
||||
await autoSetAdmin(userId)
|
||||
|
||||
const token = signToken(userId)
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
|
||||
const userInfo = (userRows as any[])[0]
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||
if (!userInfo) {
|
||||
return res.status(403).json({ code: 40300, message: '该账号已被禁用' })
|
||||
}
|
||||
|
||||
const tokens = await issueTokens(userId)
|
||||
res.json({ code: 0, data: { ...tokens, userId, nickname: userInfo.nickname || '小菜', avatar_url: userInfo.avatar_url || '', role: userInfo.role || 'user' } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Demo login failed:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
@@ -78,20 +116,62 @@ router.post('/login', async (req: Request, res: Response) => {
|
||||
// 首个用户自动设为管理员
|
||||
await autoSetAdmin(userId)
|
||||
|
||||
// HMAC signed token with expiry
|
||||
const token = signToken(userId)
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
||||
// 检查用户是否已被软删除
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
|
||||
const userInfo = (userRows as any[])[0]
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||
if (!userInfo) {
|
||||
return res.status(403).json({ code: 40300, message: '该账号已被禁用' })
|
||||
}
|
||||
|
||||
const tokens = await issueTokens(userId)
|
||||
res.json({ code: 0, data: { ...tokens, userId, nickname: userInfo.nickname || '小菜', avatar_url: userInfo.avatar_url || '', role: userInfo.role || 'user' } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Login failed:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 刷新 Access Token */
|
||||
router.post('/refresh', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { refreshToken } = req.body
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(refreshToken)
|
||||
// 查找有效记录
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()',
|
||||
[tokenHash]
|
||||
)
|
||||
if (!(rows as any[]).length) {
|
||||
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
|
||||
}
|
||||
|
||||
const record = (rows as any[])[0]
|
||||
// 撤销旧 token
|
||||
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?', [record.id])
|
||||
|
||||
// 生成新的双 token
|
||||
const newAccessToken = signToken(record.user_id)
|
||||
const newRefreshToken = generateRefreshToken()
|
||||
const newHash = hashToken(newRefreshToken)
|
||||
await pool.query(
|
||||
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||
[record.user_id, newHash]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Refresh token failed:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 首个用户自动设为管理员 */
|
||||
async function autoSetAdmin(userId: number) {
|
||||
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin'])
|
||||
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ? AND deleted_at IS NULL', ['admin'])
|
||||
if ((adminCount as any[])[0].count === 0) {
|
||||
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
|
||||
console.log(`[Auth] User ${userId} auto-promoted to admin`)
|
||||
|
||||
@@ -3,12 +3,12 @@ import { AuthRequest } from '../middleware/auth'
|
||||
import { backupDatabase, getBackupList } from '../utils/backup'
|
||||
import { requireAdmin } from '../middleware/requireAdmin'
|
||||
import { createHmac } from 'crypto'
|
||||
import { TOKEN_SECRET } from '../config/token'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
|
||||
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||||
router.use((req, res, next) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { createHmac } from 'crypto'
|
||||
import { TOKEN_SECRET } from '../config/token'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -15,7 +16,6 @@ const VALID_FORMATS = ['csv', 'json']
|
||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const LARGE_DATA_THRESHOLD = 10000
|
||||
const EXPORT_TIMEOUT = 60000
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
const DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
/** 生成短期下载签名 token */
|
||||
|
||||
@@ -86,7 +86,7 @@ router.get('/', requireAdmin, async (req, res) => {
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT f.*, u.nickname, u.avatar_url
|
||||
FROM feedbacks f
|
||||
LEFT JOIN users u ON f.user_id = u.id
|
||||
LEFT JOIN users u ON f.user_id = u.id AND u.deleted_at IS NULL
|
||||
WHERE ${where}
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
|
||||
@@ -246,7 +246,7 @@ router.get('/:id/members', async (req: AuthRequest, res: Response) => {
|
||||
`SELECT gm.user_id, gm.role, gm.created_at as joined_at,
|
||||
u.nickname, u.avatar_url
|
||||
FROM group_members gm
|
||||
LEFT JOIN users u ON gm.user_id = u.id
|
||||
LEFT JOIN users u ON gm.user_id = u.id AND u.deleted_at IS NULL
|
||||
WHERE gm.group_id = ?
|
||||
ORDER BY gm.role DESC, gm.created_at ASC`,
|
||||
[groupId]
|
||||
|
||||
@@ -160,7 +160,7 @@ router.get('/track/list', requireAdmin, async (req, res) => {
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT t.*, u.nickname
|
||||
FROM track_events t
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
|
||||
WHERE ${where}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
|
||||
@@ -57,7 +57,7 @@ async function requireNotificationEditAuth(req: AuthRequest, res: Response, next
|
||||
}
|
||||
|
||||
// 检查是否是管理员
|
||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
|
||||
const user = (userRows as any[])[0]
|
||||
if (user && user.role === 'admin') {
|
||||
return next()
|
||||
@@ -313,7 +313,7 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
// 系统公告需要管理员权限
|
||||
if (type === 'system') {
|
||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
|
||||
const user = (userRows as any[])[0]
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ code: 40300, message: '仅管理员可发布系统公告' })
|
||||
|
||||
@@ -22,7 +22,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
|
||||
WHERE t.id = ? AND (
|
||||
t.user_id = ?
|
||||
OR t.user_id IN (SELECT user_id FROM group_members WHERE group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))
|
||||
@@ -135,7 +135,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
|
||||
${tagJoin}
|
||||
${where}
|
||||
ORDER BY ${orderBy}
|
||||
|
||||
@@ -48,7 +48,7 @@ const upload = multer({
|
||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ?',
|
||||
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ? AND deleted_at IS NULL',
|
||||
[req.userId]
|
||||
)
|
||||
const user = (rows as any[])[0]
|
||||
@@ -123,7 +123,7 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
try {
|
||||
// 先查询旧头像
|
||||
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ?', [req.userId])
|
||||
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
|
||||
const oldUrl = (rows as any[])[0]?.avatar_url
|
||||
|
||||
// 先更新 DB,再删旧文件(保证 DB 和文件至少有一个正确)
|
||||
|
||||
2215
server/yarn.lock
2215
server/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user