307 lines
9.1 KiB
JavaScript
307 lines
9.1 KiB
JavaScript
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;
|