sec commit
This commit is contained in:
306
shared/code/backend/routes/auth.js
Normal file
306
shared/code/backend/routes/auth.js
Normal file
@@ -0,0 +1,306 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authMiddleware, JWT_SECRET, JWT_EXPIRES_IN } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const DATA_DIR = path.join(__dirname, '../data');
|
||||
const USERS_DIR = path.join(DATA_DIR, 'users');
|
||||
const DEVICES_DIR = path.join(DATA_DIR, 'devices');
|
||||
|
||||
// 确保目录存在
|
||||
[USERS_DIR, DEVICES_DIR].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
// 内存存储验证码(captchaKey -> { code, expiresAt })
|
||||
const captchaStore = new Map();
|
||||
|
||||
// 生成6位数字验证码
|
||||
function generateCode() {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
// 清理过期验证码
|
||||
function cleanupExpiredCodes() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of captchaStore.entries()) {
|
||||
if (value.expiresAt < now) captchaStore.delete(key);
|
||||
}
|
||||
}
|
||||
setInterval(cleanupExpiredCodes, 60000);
|
||||
|
||||
// 辅助:读取用户文件
|
||||
function getUserFilePath(phone) {
|
||||
return path.join(USERS_DIR, `${phone}.json`);
|
||||
}
|
||||
|
||||
// 辅助:读取用户
|
||||
function getUser(phone) {
|
||||
const filePath = getUserFilePath(phone);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
// 辅助:保存用户
|
||||
function saveUser(user) {
|
||||
const filePath = getUserFilePath(user.phone);
|
||||
fs.writeFileSync(filePath, JSON.stringify(user, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 辅助:读取设备列表
|
||||
function getDevices(userId) {
|
||||
const files = fs.readdirSync(DEVICES_DIR);
|
||||
return files
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => JSON.parse(fs.readFileSync(path.join(DEVICES_DIR, f), 'utf-8')))
|
||||
.filter(d => d.userId === userId);
|
||||
}
|
||||
|
||||
// 辅助:保存设备
|
||||
function saveDevice(device) {
|
||||
const filePath = path.join(DEVICES_DIR, `${device.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(device, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 辅助:删除设备
|
||||
function deleteDevice(deviceId) {
|
||||
const filePath = path.join(DEVICES_DIR, `${deviceId}.json`);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// 辅助:删除用户所有设备
|
||||
function deleteUserDevices(userId) {
|
||||
getDevices(userId).forEach(d => deleteDevice(d.id));
|
||||
}
|
||||
|
||||
// 辅助:生成 JWT
|
||||
function generateToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, phone: user.phone },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
|
||||
// F1: POST /api/auth/send-code — 发送验证码
|
||||
router.post('/send-code', (req, res) => {
|
||||
const { phone, captchaKey, captchaCode } = req.body;
|
||||
|
||||
if (!phone || !captchaKey || !captchaCode) {
|
||||
return res.status(400).json({ code: 400, message: '参数不完整' });
|
||||
}
|
||||
|
||||
// 模拟图形验证码校验(captchaCode 忽略大小写)
|
||||
// 实际生产中应查询 Redis 或存储的验证码
|
||||
const stored = captchaStore.get(captchaKey);
|
||||
if (!stored) {
|
||||
return res.status(400).json({ code: 400, message: '图形验证码已过期,请刷新' });
|
||||
}
|
||||
if (stored.code.toLowerCase() !== captchaCode.toLowerCase()) {
|
||||
return res.status(400).json({ code: 400, message: '图形验证码错误' });
|
||||
}
|
||||
|
||||
// 生成6位短信验证码,5分钟有效
|
||||
const code = generateCode();
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000;
|
||||
|
||||
// 存储短信验证码(用手机号作为子键方便后续验证)
|
||||
captchaStore.set(`sms:${phone}`, { code, expiresAt });
|
||||
|
||||
console.log(`[send-code] 手机号 ${phone} 的验证码是: ${code}`);
|
||||
|
||||
// DEBUG: 开发环境返回验证码方便测试
|
||||
const debug = process.env.NODE_ENV === 'production' ? {} : { debugCode: code };
|
||||
|
||||
return res.json({ code: 0, message: '发送成功', ...debug });
|
||||
});
|
||||
|
||||
// F1: POST /api/auth/register — 注册
|
||||
router.post('/register', (req, res) => {
|
||||
const { phone, code, password } = req.body;
|
||||
|
||||
if (!phone || !code || !password) {
|
||||
return res.status(400).json({ code: 400, message: '参数不完整' });
|
||||
}
|
||||
|
||||
// 密码长度校验
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ code: 400, message: '密码长度不能少于6位' });
|
||||
}
|
||||
|
||||
// 验证短信验证码
|
||||
const stored = captchaStore.get(`sms:${phone}`);
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ code: 400, message: '验证码已过期,请重新获取' });
|
||||
}
|
||||
if (stored.code !== code) {
|
||||
return res.status(400).json({ code: 400, message: '验证码错误' });
|
||||
}
|
||||
|
||||
// 检查是否已注册
|
||||
if (getUser(phone)) {
|
||||
return res.status(400).json({ code: 400, message: '该手机号已注册' });
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
const user = {
|
||||
id: uuidv4(),
|
||||
phone,
|
||||
passwordHash: bcrypt.hashSync(password, 10),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
saveUser(user);
|
||||
|
||||
// 清除验证码
|
||||
captchaStore.delete(`sms:${phone}`);
|
||||
|
||||
const token = generateToken(user);
|
||||
return res.json({
|
||||
code: 0,
|
||||
token,
|
||||
user: { id: user.id, phone: user.phone, createdAt: user.createdAt }
|
||||
});
|
||||
});
|
||||
|
||||
// F2: POST /api/auth/login — 登录
|
||||
router.post('/login', (req, res) => {
|
||||
const { phone, password } = req.body;
|
||||
|
||||
if (!phone || !password) {
|
||||
return res.status(400).json({ code: 400, message: '参数不完整' });
|
||||
}
|
||||
|
||||
const user = getUser(phone);
|
||||
if (!user) {
|
||||
return res.status(401).json({ code: 401, message: '手机号或密码错误' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.passwordHash)) {
|
||||
return res.status(401).json({ code: 401, message: '手机号或密码错误' });
|
||||
}
|
||||
|
||||
// 获取当前用户设备数
|
||||
let devices = getDevices(user.id);
|
||||
const MAX_DEVICES = 3;
|
||||
|
||||
// 超过3个设备,踢出最早的
|
||||
if (devices.length >= MAX_DEVICES) {
|
||||
devices.sort((a, b) => new Date(a.lastActiveAt) - new Date(b.lastActiveAt));
|
||||
const oldestDevice = devices[0];
|
||||
deleteDevice(oldestDevice.id);
|
||||
console.log(`[login] 设备数超限,踢出设备: ${oldestDevice.id}`);
|
||||
}
|
||||
|
||||
// 创建新设备记录
|
||||
const device = {
|
||||
id: uuidv4(),
|
||||
userId: user.id,
|
||||
deviceName: req.headers['x-device-name'] || '未知设备',
|
||||
deviceToken: req.headers['x-device-token'] || '',
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
saveDevice(device);
|
||||
|
||||
// 更新用户最后登录时间
|
||||
user.updatedAt = new Date().toISOString();
|
||||
saveUser(user);
|
||||
|
||||
const token = generateToken(user);
|
||||
return res.json({
|
||||
code: 0,
|
||||
token,
|
||||
user: { id: user.id, phone: user.phone, createdAt: user.createdAt }
|
||||
});
|
||||
});
|
||||
|
||||
// F3: POST /api/auth/change-password — 修改密码
|
||||
router.post('/change-password', (req, res) => {
|
||||
const { phone, code, newPassword } = req.body;
|
||||
|
||||
if (!phone || !code || !newPassword) {
|
||||
return res.status(400).json({ code: 400, message: '参数不完整' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ code: 400, message: '密码长度不能少于6位' });
|
||||
}
|
||||
|
||||
// 验证短信验证码
|
||||
const stored = captchaStore.get(`sms:${phone}`);
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ code: 400, message: '验证码已过期,请重新获取' });
|
||||
}
|
||||
if (stored.code !== code) {
|
||||
return res.status(400).json({ code: 400, message: '验证码错误' });
|
||||
}
|
||||
|
||||
const user = getUser(phone);
|
||||
if (!user) {
|
||||
return res.status(404).json({ code: 404, message: '用户不存在' });
|
||||
}
|
||||
|
||||
user.passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
user.updatedAt = new Date().toISOString();
|
||||
saveUser(user);
|
||||
|
||||
// 清除验证码 & 清除该用户所有设备(强制重新登录)
|
||||
captchaStore.delete(`sms:${phone}`);
|
||||
deleteUserDevices(user.id);
|
||||
|
||||
return res.json({ code: 0, message: '修改成功' });
|
||||
});
|
||||
|
||||
// F4: GET /api/auth/devices — 获取登录设备列表
|
||||
router.get('/devices', authMiddleware, (req, res) => {
|
||||
const devices = getDevices(req.user.id).map(d => ({
|
||||
id: d.id,
|
||||
deviceName: d.deviceName,
|
||||
deviceToken: d.deviceToken,
|
||||
lastActiveAt: d.lastActiveAt,
|
||||
createdAt: d.createdAt
|
||||
}));
|
||||
return res.json({ code: 0, devices });
|
||||
});
|
||||
|
||||
// F4: DELETE /api/auth/devices/:id — 删除设备
|
||||
router.delete('/devices/:id', authMiddleware, (req, res) => {
|
||||
const deviceId = req.params.id;
|
||||
const devices = getDevices(req.user.id);
|
||||
const device = devices.find(d => d.id === deviceId);
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({ code: 404, message: '设备不存在' });
|
||||
}
|
||||
|
||||
deleteDevice(deviceId);
|
||||
return res.json({ code: 0, message: '已下线' });
|
||||
});
|
||||
|
||||
// 模拟图形验证码:GET /api/auth/captcha 获取图形验证码
|
||||
router.get('/captcha', (req, res) => {
|
||||
const captchaKey = uuidv4();
|
||||
const captchaCode = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
|
||||
// 存储5分钟
|
||||
captchaStore.set(captchaKey, {
|
||||
code: captchaCode,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000
|
||||
});
|
||||
|
||||
// 返回 key 和一个简单的 SVG 验证码图片(文本形式,实际生产用真图片)
|
||||
// 这里返回 key 让前端存储,后续 /send-code 验证
|
||||
return res.json({
|
||||
code: 0,
|
||||
captchaKey,
|
||||
// DEBUG: 实际验证码(仅开发环境)
|
||||
captchaCode: process.env.NODE_ENV === 'production' ? undefined : captchaCode
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user