@jianghujs/jianghu
Version:
Progressive Enterprise Framework
354 lines (316 loc) • 12 kB
JavaScript
'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' },
},
},
});
class UserService extends Service {
async passwordLogin() {
const app = this.app;
const { jianghuKnex } = app;
const { cacheStorage, config } = app;
const { appId } = config;
const { enableLoginCaptcha } = config.jianghuConfig;
const { actionData } = this.ctx.request.body.appData;
validateUtil.validate(actionDataScheme.passwordLogin, actionData);
const {
userId,
password,
deviceType,
deviceId,
captchaCode,
needSetCookies = true,
} = actionData;
// 检查是否被锁定
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`);
}
}
// 检查是否需要验证码
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`);
}
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);
}
const passwordMd5 = md5(`${password}_${user.md5Salt}`);
if (passwordMd5 !== user.password) {
await this.handleLoginFailure(deviceId);
throw new BizError(errorInfoEnum.user_password_error);
}
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,
});
}
// 清除登录失败计数和锁定时间
await cacheStorage.del(`${appId}_${deviceId}_loginAttemptCount`);
await cacheStorage.del(`${appId}_${deviceId}_loginLockUntil`);
await cacheStorage.del(`${appId}_${deviceId}_loginVerifyCode`);
// 设置 cookies,用于 page 鉴权
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 };
}
// 处理登录失败
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 {};
}
}
module.exports = UserService;