UNPKG

@jianghujs/jianghu

Version:

Progressive Enterprise Framework

522 lines (461 loc) 17 kB
'use strict'; // ========================================常用 require start=========================================== const Service = require('egg').Service; const { BizError, errorInfoEnum } = require('../constant/error'); const { userStatusObj } = require('../constant/constant'); const validateUtil = require('../common/validateUtil'); const idGenerateUtil = require('../common/idGenerateUtil'); // ========================================常用 require end============================================= const md5 = require('md5-node'); const dayjs = require('dayjs'); const { ApiConfigKit, SnsAccessTokenApi, ApiConfig } = require('tnwx'); const actionDataScheme = Object.freeze({ passwordLogin: { type: 'object', additionalProperties: true, required: [ 'userId', 'password', 'deviceId' ], properties: { userId: { type: 'string', minLength: 3 }, password: { type: 'string' }, deviceId: { type: 'string' }, deviceType: { type: 'string' }, captchaCode: { type: 'string' }, needSetCookies: { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, }, }, wxLogin: { type: 'object', additionalProperties: true, required: [ 'code', 'deviceId' ], properties: { code: { type: 'string' }, deviceId: { type: 'string' }, deviceType: { type: 'string' }, needSetCookies: { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, }, }, logout: { type: 'object', additionalProperties: true, required: [], properties: { needSetCookies: { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, }, }, resetPassword: { type: 'object', additionalProperties: true, required: [ 'oldPassword', 'newPassword' ], properties: { oldPassword: { type: 'string' }, newPassword: { type: 'string' }, }, }, verifyMfaLogin: { type: 'object', additionalProperties: true, required: ['challengeId', 'mfaCode'], properties: { challengeId: { type: 'string', minLength: 1 }, mfaCode: { type: 'string', minLength: 6, maxLength: 6 }, }, }, }); class UserService extends Service { /** * 带MFA验证的登录主函数 * @returns {object} 登录结果 */ async passwordLogin() { const { jianghuKnex, cacheStorage, config } = this.app; const { appId } = config; const { enableLoginCaptcha } = config.jianghuConfig; const { actionData } = this.ctx.request.body.appData; validateUtil.validate(actionDataScheme.passwordLogin, actionData); const { userId, password, deviceType = 'web', deviceId, captchaCode, needSetCookies = true, } = actionData; // 第1步:检查是否被锁定 const lockUntil = await cacheStorage.get(`${appId}_${deviceId}_loginLockUntil`); if (lockUntil) { const now = new Date().getTime(); if (now < parseInt(lockUntil)) { throw new BizError(errorInfoEnum.login_locked); } else { await cacheStorage.del(`${appId}_${deviceId}_loginLockUntil`); await cacheStorage.del(`${appId}_${deviceId}_loginAttemptCount`); } } // 第2步:检查是否需要验证码 const loginAttemptCount = parseInt(await cacheStorage.get(`${appId}_${deviceId}_loginAttemptCount`) || '0'); if (enableLoginCaptcha && loginAttemptCount > 0) { // 未填写验证码 if (!captchaCode) { throw new BizError(errorInfoEnum.login_captcha_required); } // 检查验证码 const savedCode = await cacheStorage.get(`${appId}_${deviceId}_loginVerifyCode`); if (!savedCode) { throw new BizError(errorInfoEnum.login_captcha_expired); } if (!savedCode || savedCode.toLowerCase() !== captchaCode.toLowerCase()) { throw new BizError(errorInfoEnum.login_captcha_error); } // 正确后清除验证码 await cacheStorage.del(`${appId}_${deviceId}_loginVerifyCode`); } // 第3步:用户验证 const user = await jianghuKnex('_view01_user') .where({ userId }) .first(); if (!user || !user.userId || user.userId !== userId) { await this.handleLoginFailure(deviceId); throw new BizError(errorInfoEnum.login_user_not_exist); } const { userStatus } = user; if (userStatus !== userStatusObj.active) { if (userStatus === userStatusObj.banned) { throw new BizError(errorInfoEnum.user_banned); } throw new BizError(errorInfoEnum.user_status_error); } // 第4步:密码验证 const passwordMd5 = md5(`${password}_${user.md5Salt}`); if (passwordMd5 !== user.password) { await this.handleLoginFailure(deviceId); throw new BizError(errorInfoEnum.user_password_error); } // 检查MFA需求 const mfaRequirement = await this.checkMfaRequirement(userId); // 根据MFA需求决定登录流程 if (!mfaRequirement.needMfa && !mfaRequirement.needBind) { // 情况A: 无需MFA,直接登录 const loginResult = await this.handleLoginSuccess(user, deviceId, deviceType, needSetCookies); return { success: true, ...loginResult }; } else if (mfaRequirement.needBind) { // 情况B: 需要绑定MFA - 先让用户登录成功,然后强制跳转到绑定页面 const loginResult = await this.handleLoginSuccess(user, deviceId, deviceType, needSetCookies); return { success: true, needBind: true, message: '系统已启用MFA认证,请绑定Microsoft Authenticator', ...loginResult }; } else { // 情况C: 需要MFA验证 const challengeId = crypto.randomUUID(); // 将登录信息缓存到cacheStorage并设置5分钟过期时间 const cacheData = { userId, user, deviceId, deviceType, retryCount: 0, createdAt: Date.now() }; const { cacheStorage } = this.app; await cacheStorage.set(`mfa_login_${challengeId}`, JSON.stringify(cacheData)); await cacheStorage.expire(`mfa_login_${challengeId}`, 300); return { success: false, needMfa: true, challengeId, message: '请输入MFA验证码完成登录' }; } } // 处理登录失败 async handleLoginFailure(deviceId) { const { cacheStorage, config } = this.app; const { appId } = config; const { loginLimitTime, loginLimitAttemptCount } = config.jianghuConfig; const loginAttemptCount = parseInt(await cacheStorage.get(`${appId}_${deviceId}_loginAttemptCount`) || '0'); const newAttemptCount = loginAttemptCount + 1; await cacheStorage.set(`${appId}_${deviceId}_loginAttemptCount`, newAttemptCount); if (newAttemptCount >= loginLimitAttemptCount) { const now = dayjs(); const lockUntil = now.add(loginLimitTime, 'second'); await cacheStorage.set(`${appId}_${deviceId}_loginLockUntil`, lockUntil.valueOf()); } } async wxLogin() { const app = this.app; const { jianghuKnex } = app; const { actionData } = this.ctx.request.body.appData; validateUtil.validate(actionDataScheme.wxLogin, actionData); const { code, deviceId, deviceType, needSetCookies = true } = actionData; if (!this.app.config.wechat) { // 微信登录配置不存在 throw new BizError(errorInfoEnum.wx_login_config_error); } const { appId, appSecret } = this.app.config.wechat; if (!appId || !appSecret) { // 微信登录配置不存在 throw new BizError(errorInfoEnum.wx_login_config_error); } const apiConfig = new ApiConfig(appId, appSecret); ApiConfigKit.putApiConfig(apiConfig); ApiConfigKit.devMode = true; ApiConfigKit.setCurrentAppId(apiConfig.getAppId); // 微信登录 const accessTokenRes = await SnsAccessTokenApi.getSnsAccessToken(code); if (accessTokenRes.errcode) { // 微信登录失败 throw new BizError({ ...errorInfoEnum.wx_login_error, errorReason: accessTokenRes.errmsg }); } const { openid } = accessTokenRes; const user = await jianghuKnex('_view01_user', this.ctx) .where({ openid }) .first(); if (!user) { // '尚未绑定用户账号' throw new BizError(errorInfoEnum.user_not_exist); } const { userId } = user; const authToken = idGenerateUtil.uuid(36); // 存session 的目的是为了 // 1. 系统可以根据这个判断是否是自己生成的token // 2. 有时候系统升级需要 用户重新登陆/重新登陆,这时候可以通过清理旧session达到目的 const userSession = await jianghuKnex('_user_session') .where({ userId, deviceId }) .first(); const userAgent = this.ctx.request.body.appData.userAgent || ''; const userIp = this.ctx.header['x-real-ip'] || this.ctx.request.ip || ''; if (userSession && userSession.id) { await jianghuKnex('_user_session', this.ctx) .where({ id: userSession.id }) .jhUpdate({ authToken, deviceType, userAgent, userIp }); } else { await jianghuKnex('_user_session', this.ctx).jhInsert({ userId, deviceId, userAgent, userIp, deviceType, authToken, }); } // 设置 cookies,用于 page 鉴权 if (needSetCookies) { this.ctx.cookies.set(`${this.ctx.app.config.appId}_authToken`, authToken, { httpOnly: false, signed: false, maxAge: 1000 * 60 * 60 * 24 * 1080, }); // 1080天 } return { authToken, deviceId, userId }; } async logout() { const { config, jianghuKnex } = this.app; const { userInfo } = this.ctx; const { actionData } = this.ctx.request.body.appData; validateUtil.validate(actionDataScheme.logout, actionData); const { needSetCookies = true } = actionData; const { userId, deviceId } = userInfo.user; if (needSetCookies) { this.ctx.cookies.set(`${config.authTokenKey}_authToken`, null); } const user = await jianghuKnex('_view01_user') .where({ userId }) .first(); if (!user || !userId) { throw new BizError({ ...errorInfoEnum.user_not_exist }); } const userSession = await jianghuKnex('_user_session') .where({ userId, deviceId }) .first(); if (userSession) { await jianghuKnex('_user_session', this.ctx) .where({ id: userSession.id }) .jhUpdate({ authToken: '' }); } return {}; } async userInfo() { const { userInfo } = this.ctx; const { user } = userInfo; const { userId } = user; const { jianghuKnex, config } = this.app; if (userId) { if (config.authTokenKey == config.appId) { userInfo.socketList = await jianghuKnex('_user_session') .where({ userId, socketStatus: 'online' }) .select('userId', 'deviceId', 'socketStatus'); } if (config.authTokenKey != config.appId) { userInfo.socketList = await jianghuKnex(`_${config.authTokenKey}_user_session`) .where({ userId, socketStatus: 'online' }) .select('userId', 'deviceId', 'socketStatus'); } } return userInfo; } async resetPassword() { const { actionData } = this.ctx.request.body.appData; validateUtil.validate(actionDataScheme.resetPassword, actionData); const app = this.app; const { jianghuKnex } = app; const { oldPassword, newPassword } = actionData; const { userInfo: { user: { userId }, }, } = this.ctx; const user = await jianghuKnex('_user').where({ userId }).first(); // 旧密码检查 const passwordMd5 = md5(`${oldPassword}_${user.md5Salt}`); if (passwordMd5 !== user.password) { throw new BizError(errorInfoEnum.user_password_reset_old_error); } // 密码一致检查 if (oldPassword === newPassword) { throw new BizError(errorInfoEnum.user_password_reset_same_error); } // 修改数据库中密码 const newMd5Salt = idGenerateUtil.uuid(12); const newPasswordMd5 = md5(`${newPassword}_${newMd5Salt}`); await jianghuKnex('_user', this.ctx).where({ userId }).jhUpdate({ password: newPasswordMd5, clearTextPassword: newPassword, md5Salt: newMd5Salt, }); await jianghuKnex('_user_session', this.ctx).where({ userId }).jhUpdate({ authToken: '' }); return {}; } // 处理登录成功(封装passwordLogin的第5-7步) async handleLoginSuccess(user, deviceId, deviceType = 'web', needSetCookies = true) { const { jianghuKnex, cacheStorage, config } = this.app; const { appId } = config; const { userId } = user; const authToken = idGenerateUtil.uuid(36); // 第5步:会话管理 - 创建authToken和session const userSession = await jianghuKnex('_user_session') .where({ userId, deviceId }) .first(); const userAgent = this.ctx.request.body.appData.userAgent || ''; const userIp = this.ctx.header['x-real-ip'] || this.ctx.request.ip || ''; if (userSession && userSession.id) { await jianghuKnex('_user_session', this.ctx) .where({ id: userSession.id }) .jhUpdate({ authToken, deviceType, userAgent, userIp }); } else { await jianghuKnex('_user_session', this.ctx).jhInsert({ userId, deviceId, userAgent, userIp, deviceType, authToken, }); } // 第6步:清理缓存 - 清除失败计数和验证码 await cacheStorage.del(`${appId}_${deviceId}_loginAttemptCount`); await cacheStorage.del(`${appId}_${deviceId}_loginLockUntil`); await cacheStorage.del(`${appId}_${deviceId}_loginVerifyCode`); // 第7步:设置Cookie - 用于页面鉴权 if (needSetCookies) { this.ctx.cookies.set(`${config.authTokenKey}_authToken`, authToken, { httpOnly: false, signed: false, maxAge: 1000 * 60 * 60 * 24 * 1080, }); // 1080天 } return { authToken, deviceId, userId }; } /** * 检查MFA需求 * @param {string} userId - 用户ID * @returns {object} MFA需求检查结果 */ async checkMfaRequirement(userId) { const { app } = this; const { jianghuKnex, config } = app; try { // 读取MFA配置 const enableMfaVerification = config.jianghuConfig?.enableMfaVerification || false; // 检查用户是否已绑定MFA const user = await jianghuKnex('_view01_user') .where({ userId }) .first(); const hasSecretKey = !!(user?.secretKey); let needMfa = false; let needBind = false; // 根据配置和用户状态决定MFA需求 if (enableMfaVerification) { // 强制MFA模式 if (!hasSecretKey) { needMfa = false; needBind = true; } else { needMfa = true; needBind = false; } } return { needMfa, needBind, hasSecretKey }; } catch (error) { throw error; } } /** * 验证MFA码并完成登录 * @returns {object} 登录结果 */ async verifyMfaLogin() { const { ctx } = this; // 获取并验证请求参数 const { actionData } = ctx.request.body.appData; validateUtil.validate(actionDataScheme.verifyMfaLogin, actionData); const { challengeId, mfaCode } = actionData; try { // 1. 从cacheStorage中获取登录信息 const { cacheStorage } = this.app; const cacheDataStr = await cacheStorage.get(`mfa_login_${challengeId}`); const cacheData = cacheDataStr ? JSON.parse(cacheDataStr) : null; // 2. 调用mfaService进行MFA验证 const mfaResult = await ctx.service.mfaService.verifyMfaLogin(challengeId, mfaCode, cacheData); // 3. 根据cacheAction更新缓存 if (mfaResult.cacheAction === 'UPDATE_RETRY' && cacheData) { cacheData.retryCount = mfaResult.newRetryCount; await cacheStorage.set(`mfa_login_${challengeId}`, JSON.stringify(cacheData)); await cacheStorage.expire(`mfa_login_${challengeId}`, 300); } else if (mfaResult.cacheAction === 'DELETE_CACHE') { await cacheStorage.del(`mfa_login_${challengeId}`); } if (!mfaResult.success) { // MFA验证失败 return { success: false, errorType: mfaResult.errorType, message: mfaResult.message }; } // 4. MFA验证成功,完成登录 const { user, deviceId, deviceType } = mfaResult; const loginResult = await this.handleLoginSuccess(user, deviceId, deviceType); return { success: true, ...loginResult }; } catch (error) { ctx.logger.error('MFA验证登录失败:', error); return { success: false, errorType: 'SYSTEM_ERROR', message: '系统错误,请稍后重试' }; } } } module.exports = UserService;