运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。 已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
611 lines
21 KiB
JavaScript
611 lines
21 KiB
JavaScript
'use strict';
|
||
|
||
const Controller = require('egg').Controller;
|
||
const svgCaptcha = require('svg-captcha');
|
||
const VALIDATION_RULES = require('../validate');
|
||
|
||
/**
|
||
* 认证控制器 - AuthController
|
||
*
|
||
* 负责选手和管理员的登录认证、账号激活、验证码、密码重置等功能。
|
||
* 使用 Contestant 模型进行选手认证,Admin 模型进行管理员认证。
|
||
* 密码采用 bcrypt 加盐哈希存储;登录时兼容存量 SHA256 旧哈希并在验证成功后透明升级为 bcrypt。
|
||
* 统一响应格式:ctx.helper.success(data, msg) / ctx.helper.error(code, msg)
|
||
*
|
||
* 主要接口:
|
||
* - POST /api/auth/captcha : 获取图形验证码(SVG)
|
||
* - POST /api/auth/sms : 发送短信验证码(模拟)
|
||
* - POST /api/auth/activate : 激活账号(设置密码)
|
||
* - POST /api/auth/login : 选手登录
|
||
* - POST /api/auth/admin-login : 管理员登录
|
||
* - GET /api/auth/current-user : 获取当前用户信息(需认证)
|
||
* - POST /api/auth/forgot-password : 忘记密码 - 发送验证码
|
||
* - POST /api/auth/reset-password : 重置密码
|
||
* - POST /api/auth/logout : 退出登录(需认证)
|
||
*/
|
||
class AuthController extends Controller {
|
||
/**
|
||
* 交付短信验证码(统一入口,供 sms / forgotPassword 复用)
|
||
*
|
||
* 行为由运行模式(config.isProduction,来自环境变量 APP_ENV)驱动,
|
||
* 使测试版本与生产版本代码路径完全一致、仅差异于配置:
|
||
* - 生产环境 + 网关已开启(config.sms.realSend):通过阿里云真实下发,
|
||
* 验证码绝不写入 HTTP 响应体、不打印到控制台(防凭身份证号接管账号)。
|
||
* - 测试环境(APP_ENV=test,或缺省):【不真实发送】验证码明文随响应返回
|
||
* (字段 smsCode),由前端弹窗展示,便于测试人员直接获取,无需配置短信网关。
|
||
* - 生产环境 + 网关未开启:安全起见不下发、不回传,仅记录告警(需运维配置 SMS_ENABLED=1)。
|
||
*
|
||
* @param {object} contestant - 选手对象(含 phone)
|
||
* @param {string} smsCode - 6 位验证码
|
||
* @param {string} sceneLabel - 场景标签(如 '账号激活' / '忘记密码')
|
||
* @return {boolean} 是否允许在响应体中回传验证码(仅测试环境为 true)
|
||
*/
|
||
async _deliverSmsCode(contestant, smsCode, sceneLabel) {
|
||
const { ctx, app } = this;
|
||
const sms = app.config.sms || {};
|
||
const isProduction = app.config.isProduction;
|
||
|
||
// 服务端审计日志(仅落日志文件,不含验证码明文回传前端)
|
||
ctx.logger.info(
|
||
`[短信验证码] 场景=${sceneLabel} 手机号=${contestant.phone} 环境=${app.config.runMode} realSend=${!!sms.realSend}`
|
||
);
|
||
|
||
// 生产环境 + 网关已开启 → 真实下发,绝不回传验证码明文
|
||
if (isProduction && sms.realSend) {
|
||
try {
|
||
await ctx.service.sms.sendVerifyCode(contestant.phone, smsCode);
|
||
ctx.logger.info(`[短信验证码] 场景=${sceneLabel} 已通过网关下发至 ${contestant.phone}`);
|
||
} catch (e) {
|
||
// 下发失败:记录告警;验证码已存入 session,用户可重试获取,绝不在响应回传
|
||
ctx.logger.error(`[短信验证码] 场景=${sceneLabel} 网关下发失败(${contestant.phone}):${e && e.message}`);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (isProduction) {
|
||
// 生产环境未开启网关:为避免账号接管风险,绝不回传或打印验证码,仅记录告警
|
||
ctx.logger.warn('[短信验证码] 生产环境未启用短信网关(realSend=false),验证码未下发。请设置 SMS_ENABLED=1 并配置阿里云密钥。');
|
||
return false;
|
||
}
|
||
|
||
// 测试环境:不真实发送,回传验证码供前端弹窗显示(控制台同步打印便于联调)
|
||
console.log('');
|
||
console.log('╔══════════════════════════════════════════════╗');
|
||
console.log(`║ 📱 [测试环境] 模拟短信验证码 - ${sceneLabel}`);
|
||
console.log('╠══════════════════════════════════════════════╣');
|
||
console.log(`║ 手机号 : ${contestant.phone}`);
|
||
console.log(`║ 验证码 : ${smsCode}`);
|
||
console.log('║ 有效期 : 5 分钟');
|
||
console.log('╚══════════════════════════════════════════════╝');
|
||
console.log('');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 获取点击式验证码(按顺序点击文字)
|
||
*
|
||
* 生成 4 个随机中文汉字,指定点击顺序,返回打乱后的文字和提示。
|
||
* 用户需按照提示顺序依次点击文字,点击序列存入 session 用于校验。
|
||
*/
|
||
async captcha() {
|
||
const { ctx } = this;
|
||
|
||
// 字符池(常用中文字符,避免生僻字)
|
||
const charPool = ['天', '地', '人', '和', '山', '水', '风', '火', '日', '月', '星', '辰', '花', '鸟', '云', '雨'];
|
||
|
||
// 随机选取 4 个不重复字符
|
||
const pool = [...charPool];
|
||
const selected = [];
|
||
for (let i = 0; i < 4; i++) {
|
||
const idx = Math.floor(Math.random() * pool.length);
|
||
selected.push(pool.splice(idx, 1)[0]);
|
||
}
|
||
|
||
// 存入 session:点击顺序的字符串(如 "天地人和")
|
||
const clickOrder = selected.join('');
|
||
ctx.session.captcha = clickOrder;
|
||
|
||
// 打乱显示顺序
|
||
const displayOrder = [...selected].sort(() => Math.random() - 0.5);
|
||
|
||
ctx.body = ctx.helper.success({
|
||
chars: displayOrder,
|
||
hint: '请依次点击:' + selected.join(' → '),
|
||
}, '验证码获取成功');
|
||
}
|
||
|
||
/**
|
||
* 发送短信验证码(模拟)
|
||
*
|
||
* 前端传入身份证号作为 phone 参数,后端根据身份证号查找选手的真实手机号。
|
||
*
|
||
* 流程:
|
||
* 1. 校验图形验证码
|
||
* 2. 通过身份证号查找参赛选手
|
||
* 3. 生成 6 位随机数字验证码
|
||
* 4. 存入 ctx.session.smsCode / smsPhone / smsIdNumber
|
||
* 5. 返回真实手机号、姓名、组别和模拟验证码
|
||
*/
|
||
async sms() {
|
||
const { ctx, service } = this;
|
||
|
||
const { phone, captcha } = ctx.request.body;
|
||
|
||
// 统一输入校验(ctx.validate)
|
||
try {
|
||
ctx.validate(VALIDATION_RULES.sms, ctx.request.body);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
// 校验图形验证码
|
||
if (!ctx.session.captcha || captcha !== ctx.session.captcha) {
|
||
ctx.body = ctx.helper.error(1002, '图形验证码错误');
|
||
return;
|
||
}
|
||
|
||
// 通过身份证号查找选手
|
||
const contestant = await service.auth.findByAccount(phone);
|
||
if (!contestant) {
|
||
ctx.body = ctx.helper.error(1003, '该身份证号不在参赛名单中,请联系管理员');
|
||
return;
|
||
}
|
||
|
||
// 检查是否已激活
|
||
if (contestant.activated) {
|
||
ctx.body = ctx.helper.error(1004, '该账号已激活,请直接登录');
|
||
return;
|
||
}
|
||
|
||
// 生成 6 位随机数字验证码
|
||
const smsCode = ctx.helper.randomCode(6);
|
||
|
||
// 存入 session(5 分钟有效期)
|
||
ctx.session.smsCode = smsCode;
|
||
ctx.session.smsPhone = contestant.phone;
|
||
ctx.session.smsIdNumber = phone;
|
||
ctx.session.smsCodeExpire = Date.now() + 5 * 60 * 1000;
|
||
|
||
// 清除已使用的图形验证码
|
||
ctx.session.captcha = null;
|
||
|
||
// 交付验证码(生产环境不回传/不打印,仅走真实网关)
|
||
const exposeCode = await this._deliverSmsCode(contestant, smsCode, '账号激活');
|
||
|
||
const respData = {
|
||
phone: contestant.phone,
|
||
name: contestant.name,
|
||
group: contestant.group,
|
||
};
|
||
if (exposeCode) respData.smsCode = smsCode;
|
||
|
||
ctx.body = ctx.helper.success(respData, '短信验证码发送成功');
|
||
}
|
||
|
||
/**
|
||
* 激活账号
|
||
*
|
||
* 流程:
|
||
* 1. 校验身份证号、手机号、短信验证码
|
||
* 2. 通过身份证号查找选手
|
||
* 3. 检查账号是否已激活
|
||
* 4. SHA256 哈希密码,更新 activated=1
|
||
* 5. 清除 session 中的短信验证码
|
||
*/
|
||
async activate() {
|
||
const { ctx, service } = this;
|
||
|
||
const { id_number, phone, smsCode, password } = ctx.request.body;
|
||
|
||
// 统一输入校验(ctx.validate)
|
||
try {
|
||
ctx.validate(VALIDATION_RULES.activate, ctx.request.body);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
// 校验短信验证码
|
||
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
|
||
ctx.body = ctx.helper.error(2005, '短信验证码错误');
|
||
return;
|
||
}
|
||
|
||
// 校验短信验证码是否过期
|
||
if (ctx.session.smsCodeExpire && Date.now() > ctx.session.smsCodeExpire) {
|
||
ctx.body = ctx.helper.error(2006, '短信验证码已过期');
|
||
return;
|
||
}
|
||
|
||
// 校验手机号一致性
|
||
if (ctx.session.smsPhone !== phone && ctx.session.smsIdNumber !== id_number) {
|
||
ctx.body = ctx.helper.error(2007, '手机号与发送验证码时的手机号不一致');
|
||
return;
|
||
}
|
||
|
||
// 通过身份证号查找选手
|
||
const contestant = await service.auth.findByAccount(id_number);
|
||
if (!contestant) {
|
||
ctx.body = ctx.helper.error(2008, '该身份证号不在参赛名单中,请联系管理员');
|
||
return;
|
||
}
|
||
|
||
// 检查是否已激活
|
||
if (contestant.activated) {
|
||
ctx.body = ctx.helper.error(2009, '该账号已激活,请直接登录');
|
||
return;
|
||
}
|
||
|
||
// bcrypt 加盐哈希密码
|
||
const hashedPassword = await ctx.helper.hashPassword(password);
|
||
|
||
// 更新选手:激活状态 + 密码
|
||
await service.auth.activateAccount(contestant.id, hashedPassword);
|
||
|
||
// 清除 session 中的短信验证码
|
||
ctx.session.smsCode = null;
|
||
ctx.session.smsPhone = null;
|
||
ctx.session.smsIdNumber = null;
|
||
ctx.session.smsCodeExpire = null;
|
||
|
||
ctx.body = ctx.helper.success({
|
||
id: contestant.id,
|
||
id_number: contestant.id_number,
|
||
name: contestant.name,
|
||
group: contestant.group,
|
||
}, '账号激活成功');
|
||
}
|
||
|
||
/**
|
||
* 选手登录 - 使用手机号
|
||
*
|
||
* 流程:
|
||
* 1. 通过手机号查找选手
|
||
* 2. 检查账号是否激活
|
||
* 3. SHA256(输入密码) 与数据库密码比对
|
||
* 4. 生成 JWT Token,返回用户信息和 Token
|
||
*/
|
||
async login() {
|
||
const { ctx, service } = this;
|
||
|
||
const { phone, password } = ctx.request.body;
|
||
|
||
// 统一输入校验(ctx.validate)
|
||
try {
|
||
ctx.validate(VALIDATION_RULES.login, ctx.request.body);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
// 查找选手
|
||
const contestant = await service.auth.findByPhone(phone);
|
||
if (!contestant) {
|
||
ctx.body = ctx.helper.error(3003, '该手机号不在参赛名单中');
|
||
return;
|
||
}
|
||
|
||
// 检查是否激活
|
||
if (!contestant.activated) {
|
||
ctx.body = ctx.helper.error(3004, '账号未激活,请先激活');
|
||
return;
|
||
}
|
||
|
||
// 验证密码(兼容 bcrypt 与存量 SHA256)
|
||
const isPasswordValid = await service.auth.verifyPassword(password, contestant.password);
|
||
if (!isPasswordValid) {
|
||
ctx.body = ctx.helper.error(3005, '密码错误');
|
||
return;
|
||
}
|
||
|
||
// 若为旧版 SHA256 哈希,登录成功后透明升级为 bcrypt
|
||
await service.auth.upgradePasswordIfLegacy(contestant, password);
|
||
|
||
// 生成 JWT Token
|
||
const token = ctx.helper.generateToken({
|
||
id: contestant.id,
|
||
phone: contestant.phone,
|
||
role: 'contestant',
|
||
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||
|
||
// 返回用户信息(排除密码)
|
||
const userData = contestant.toJSON();
|
||
delete userData.password;
|
||
|
||
ctx.body = ctx.helper.success({
|
||
token,
|
||
user: userData,
|
||
}, '登录成功');
|
||
}
|
||
|
||
/**
|
||
* 管理员登录
|
||
*
|
||
* 流程:
|
||
* 1. 通过用户名从 admins 表查找管理员
|
||
* 2. 密码比对(SHA256)
|
||
* 3. 生成管理员 JWT Token
|
||
*/
|
||
async adminLogin() {
|
||
const { ctx, service } = this;
|
||
|
||
const { username, password } = ctx.request.body;
|
||
|
||
// 统一输入校验(ctx.validate)
|
||
try {
|
||
ctx.validate(VALIDATION_RULES.adminLogin, ctx.request.body);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
const admin = await service.auth.findAdmin(username);
|
||
if (!admin) {
|
||
ctx.body = ctx.helper.error(4003, '管理员不存在');
|
||
return;
|
||
}
|
||
|
||
// 管理员密码比对(兼容 bcrypt 与存量 SHA256)
|
||
const isValid = await service.auth.verifyPassword(password, admin.password);
|
||
if (!isValid) {
|
||
ctx.body = ctx.helper.error(4004, '密码错误');
|
||
return;
|
||
}
|
||
|
||
// P2-3 默认口令守卫:schema.sql 内置默认管理员(admin/admin123),
|
||
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
||
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
||
const DEFAULT_ADMIN_PASSWORDS = ['admin123', 'admin123456'];
|
||
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
|
||
const isProd = this.app.config.isProduction;
|
||
if (usingDefaultPassword && isProd) {
|
||
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
|
||
ctx.body = ctx.helper.error(4005, '生产环境禁止使用默认密码登录,请联系运维在数据库中重置为强密码后再试');
|
||
return;
|
||
}
|
||
if (usingDefaultPassword) {
|
||
ctx.logger.warn(`[安全提醒] 管理员 ${admin.username} 正在使用默认密码,请尽快修改`);
|
||
}
|
||
|
||
// 若为旧版 SHA256 哈希,登录成功后透明升级为 bcrypt
|
||
await service.auth.upgradePasswordIfLegacy(admin, password);
|
||
|
||
const token = ctx.helper.generateToken({
|
||
id: admin.id,
|
||
role: 'admin',
|
||
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||
|
||
ctx.body = ctx.helper.success({
|
||
token,
|
||
user: {
|
||
id: admin.id,
|
||
username: admin.username,
|
||
role: 'admin',
|
||
},
|
||
// 使用默认口令时向前端透出改密提醒标记
|
||
mustChangePassword: usingDefaultPassword,
|
||
}, '管理员登录成功');
|
||
}
|
||
|
||
/**
|
||
* 获取当前登录用户信息(需认证)
|
||
*/
|
||
async currentUser() {
|
||
const { ctx } = this;
|
||
|
||
// 从 JWT token 中获取用户信息
|
||
const token = ctx.headers.authorization
|
||
? ctx.headers.authorization.replace('Bearer ', '')
|
||
: null;
|
||
|
||
if (!token) {
|
||
ctx.body = ctx.helper.error(5001, '未登录');
|
||
return;
|
||
}
|
||
|
||
let decoded;
|
||
try {
|
||
decoded = ctx.helper.verifyToken(token, this.app.config.jwt.secret);
|
||
} catch (e) {
|
||
ctx.body = ctx.helper.error(5002, 'Token 已过期,请重新登录');
|
||
return;
|
||
}
|
||
|
||
if (!decoded || !decoded.id) {
|
||
ctx.body = ctx.helper.error(5003, 'Token 无效');
|
||
return;
|
||
}
|
||
|
||
const contestant = await ctx.model.Contestant.findOne({
|
||
where: { id: decoded.id },
|
||
});
|
||
|
||
if (!contestant) {
|
||
ctx.body = ctx.helper.error(5001, '用户不存在');
|
||
return;
|
||
}
|
||
|
||
const userData = contestant.toJSON();
|
||
delete userData.password;
|
||
|
||
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
|
||
}
|
||
|
||
/**
|
||
* 忘记密码 - 发送验证码
|
||
*/
|
||
async forgotPassword() {
|
||
const { ctx, service } = this;
|
||
|
||
const { id_number, captcha } = ctx.request.body;
|
||
|
||
// 统一输入校验(ctx.validate)
|
||
try {
|
||
ctx.validate(VALIDATION_RULES.forgotPassword, ctx.request.body);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
if (!ctx.session.captcha || captcha !== ctx.session.captcha) {
|
||
ctx.body = ctx.helper.error(6002, '图形验证码错误');
|
||
return;
|
||
}
|
||
|
||
const contestant = await service.auth.findByAccount(id_number);
|
||
if (!contestant) {
|
||
ctx.body = ctx.helper.error(6003, '该身份证号不在参赛名单中');
|
||
return;
|
||
}
|
||
|
||
if (!contestant.activated) {
|
||
ctx.body = ctx.helper.error(6004, '该账号尚未激活,请先激活');
|
||
return;
|
||
}
|
||
|
||
const smsCode = ctx.helper.randomCode(6);
|
||
|
||
ctx.session.smsCode = smsCode;
|
||
ctx.session.smsPhone = contestant.phone;
|
||
ctx.session.smsIdNumber = id_number;
|
||
ctx.session.smsCodeExpire = Date.now() + 5 * 60 * 1000;
|
||
|
||
ctx.session.captcha = null;
|
||
|
||
// 交付验证码(生产环境不回传/不打印,仅走真实网关)
|
||
const exposeCode = await this._deliverSmsCode(contestant, smsCode, '忘记密码');
|
||
|
||
const respData = { phone: contestant.phone };
|
||
if (exposeCode) respData.smsCode = smsCode;
|
||
|
||
ctx.body = ctx.helper.success(respData, '验证码已发送');
|
||
}
|
||
|
||
/**
|
||
* 重置密码
|
||
*/
|
||
async resetPassword() {
|
||
const { ctx, service } = this;
|
||
|
||
const { id_number, smsCode, password } = ctx.request.body;
|
||
|
||
// 统一输入校验(ctx.validate)
|
||
try {
|
||
ctx.validate(VALIDATION_RULES.resetPassword, ctx.request.body);
|
||
} catch (validateErr) {
|
||
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
|
||
ctx.body = ctx.helper.error(7004, '短信验证码错误');
|
||
return;
|
||
}
|
||
|
||
if (ctx.session.smsCodeExpire && Date.now() > ctx.session.smsCodeExpire) {
|
||
ctx.body = ctx.helper.error(7005, '短信验证码已过期');
|
||
return;
|
||
}
|
||
|
||
const contestant = await service.auth.findByAccount(id_number);
|
||
if (!contestant) {
|
||
ctx.body = ctx.helper.error(7006, '账号不存在');
|
||
return;
|
||
}
|
||
|
||
const hashedPassword = await ctx.helper.hashPassword(password);
|
||
await contestant.update({ password: hashedPassword });
|
||
|
||
ctx.session.smsCode = null;
|
||
ctx.session.smsPhone = null;
|
||
ctx.session.smsIdNumber = null;
|
||
ctx.session.smsCodeExpire = null;
|
||
|
||
ctx.body = ctx.helper.success(null, '密码重置成功');
|
||
}
|
||
|
||
/**
|
||
* 退出登录
|
||
*/
|
||
async logout() {
|
||
const { ctx } = this;
|
||
|
||
ctx.body = ctx.helper.success(null, '退出登录成功');
|
||
}
|
||
|
||
/**
|
||
* 刷新 Token(静默续期,用户无感知)
|
||
*
|
||
* 设计目标:让活跃用户永远不会被"意外踢出登录"。
|
||
* - 接受可能已过期的令牌(verifyToken 忽略过期)。
|
||
* - 仅校验签名有效 + 用户仍真实存在且已激活。
|
||
* - 在宽限期内(refreshGrace)允许刷新;超过宽限期则必须重新登录。
|
||
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
|
||
*/
|
||
async refresh() {
|
||
const { ctx } = this;
|
||
|
||
const authorization = ctx.headers.authorization || '';
|
||
const token = authorization.replace(/^Bearer\s+/i, '') || null;
|
||
if (!token) {
|
||
ctx.body = ctx.helper.error(5001, '未登录');
|
||
return;
|
||
}
|
||
|
||
let decoded;
|
||
try {
|
||
// 忽略过期,仅校验签名与载荷结构,用于静默续期
|
||
decoded = ctx.helper.verifyToken(token, this.app.config.jwt.secret, { ignoreExpiration: true });
|
||
} catch (e) {
|
||
ctx.body = ctx.helper.error(5002, 'Token 无效,请重新登录');
|
||
return;
|
||
}
|
||
if (!decoded || !decoded.id) {
|
||
ctx.body = ctx.helper.error(5003, 'Token 无效,请重新登录');
|
||
return;
|
||
}
|
||
|
||
// 宽限期校验:仅允许在过期后 refreshGrace 秒内续期,防止会话永久不失效
|
||
const nowSec = Math.floor(Date.now() / 1000);
|
||
const expiredAgo = decoded.exp ? nowSec - decoded.exp : 99999999;
|
||
const refreshGrace = this.app.config.jwt.refreshGrace || 7 * 24 * 60 * 60;
|
||
if (expiredAgo > refreshGrace) {
|
||
ctx.body = ctx.helper.error(5004, '登录已过期太久,请重新登录');
|
||
return;
|
||
}
|
||
|
||
// 校验用户仍真实存在(且选手需已激活)
|
||
let account = null;
|
||
if (decoded.role === 'admin') {
|
||
account = await ctx.model.Admin.findOne({ where: { id: decoded.id } });
|
||
} else {
|
||
account = await ctx.model.Contestant.findOne({ where: { id: decoded.id } });
|
||
if (account && !account.activated) {
|
||
ctx.body = ctx.helper.error(5001, '账号未激活,请先激活');
|
||
return;
|
||
}
|
||
}
|
||
if (!account) {
|
||
ctx.body = ctx.helper.error(5001, '用户不存在,请重新登录');
|
||
return;
|
||
}
|
||
|
||
// 签发新令牌(沿用登录时的有效期配置)
|
||
const payload = decoded.role === 'admin'
|
||
? { id: account.id, role: 'admin' }
|
||
: { id: account.id, phone: account.phone, role: 'contestant' };
|
||
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||
|
||
const userData = account.toJSON();
|
||
delete userData.password;
|
||
|
||
ctx.body = ctx.helper.success({
|
||
token: newToken,
|
||
user: userData,
|
||
}, '刷新成功');
|
||
}
|
||
}
|
||
|
||
module.exports = AuthController;
|