'use strict'; const Controller = require('egg').Controller; const svgCaptcha = require('svg-captcha'); const VALIDATION_RULES = require('../validate'); // 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。 // ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。 const passwordPolicy = require('../lib/passwordPolicy'); /** * 认证控制器 - 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. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 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; } // 口令强度校验(R63 · C1) // 背景:此前仅由 validate 校验「长度 ≥ 6」,`123456` 完全合法 —— R61 测试人员 // 实测「浏览器密码管理器预填 123456,直接提交后即成为账号口令」。 // 位置(validate 之后 / 短信码之前)的理由: // · validate 只管「必填 + 格式」,强度是业务规则,分层后两者失败文案互不覆盖; // · 纯本地计算、无副作用,可快速失败,不消耗也不依赖 session 中的验证码。 // 错误码 2001 = 口令强度不足(activate 段编码;resetPassword 段用 7001)。 // ⚠️ 日志只记命中的规则名,绝不记录口令明文或身份证号。 const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number, phone }); if (!pwdCheck.ok) { ctx.logger.info(`[口令策略] 激活口令被拒:rule=${pwdCheck.rule}`); ctx.body = ctx.helper.error(2001, pwdCheck.reason); 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. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希, * 并在验证成功后透明升级为 bcrypt) * 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); // 返回用户信息 —— 统一走 AuthService.toClientUser 出口: // 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21) const userData = await service.auth.toClientUser(contestant); ctx.body = ctx.helper.success({ token, user: userData, }, '登录成功'); } /** * 管理员登录 * * 流程: * 1. 通过用户名从 admins 表查找管理员 * 2. bcrypt 比对密码(兼容存量 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 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js), // 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管; // 非生产环境放行但记录告警,并在响应中提示尽快改密。 // // ⚠️ 【R111 更正 · 实测】原注释写「schema.sql 内置默认管理员(admin/admin123)」,两处失实: // ① 归属错 —— `admin123` 是 **seeder 002** 的 bcrypt 值;`schema.sql:214` 的字面哈希实测 = SHA256(`admin`)。 // ② 列表漏项 —— 原列表 `['admin123','admin123456']` **不含 `admin`**,而 `schema.sql` 建库路径的初始 // 口令恰为 `admin` ⇒ 生产若未改密,用 `admin/admin` 登录**不会**触发 4005(`verifyPassword` 兼容 // SHA256 ⇒ 可直接登录成功)⇒ 恰是本守卫要防的场景却不设防。已补入 `'admin'`。 // 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。 // ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过; // 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。 // ✅ 【R112 已消除 · 候选㉒】上述「口令字面」单通道已升级为**双通道**,并把清单下沉为**单源**: // ① 口令通道:`DEFAULT_ADMIN_PASSWORDS.includes(password)`(原逻辑原样保留); // ② 哈希通道:`isDefaultAdminHash(admin.password)` —— **与用户输入无关**,直接判 DB 存储值 // 是否为已发布的默认哈希(无盐 SHA256 + 固定盐 bcrypt),并对候选口令做 bcrypt 遍历, // 以覆盖「同口令、**不同盐**」的重新播种场景(见 `002-seed-admin.js` 的 ADMIN_INIT_PASSWORD 分支)。 // ⇒ 上面那条「未枚举即绕过」的绕过面已消除;仅剩「新增内置凭据未登记」这一**显式维护点** // (清单已下沉至 `lib/passwordPolicy.js` 模块头 ⚠️,⛔ 勿在本控制器内再写第二份)。 const usingDefaultPassword = passwordPolicy.isDefaultAdminHash(admin.password) || passwordPolicy.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, service } = 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; } // 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致, // 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score) const userData = await service.auth.toClientUser(contestant); 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; } // 口令强度校验(R63 · C1)—— 与 activate() 共用同一策略(lib/passwordPolicy.js), // ⚠️ 禁止在本处另行实现:一旦「忘记密码」通道放宽,弱口令缺口会从另一条路重新打开 // (这正是本模块必须单源的原因)。 // 错误码 7001 = 口令强度不足(resetPassword 段编码;activate 段用 2001)。 // 本入口请求体无 phone 字段(见 validate.resetPassword),故仅传 idNumber 做派生值比对。 const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number }); if (!pwdCheck.ok) { ctx.logger.info(`[口令策略] 重置口令被拒:rule=${pwdCheck.rule}`); ctx.body = ctx.helper.error(7001, pwdCheck.reason); 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, service } = 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); // 返回用户信息 —— 统一走 AuthService.toClientUser 出口 // (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出) const userData = await service.auth.toClientUser(account); ctx.body = ctx.helper.success({ token: newToken, user: userData, }, '刷新成功'); } } module.exports = AuthController;