Files
competition-website/app/controller/auth.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

611 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;