diff --git a/.gitignore b/.gitignore index a571d04..2487b48 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ suspect/ dist learn tokens.json -app/assets \ No newline at end of file +assets \ No newline at end of file diff --git a/README.md b/README.md index 03c1a0b..9f9ad24 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,12 @@ QQ 群号:643205479 / 814597236 ## 版本日志 -最新版本 `0.3.11` +最新版本 `0.3.12` + +### 0.3.12 + +1. `A` 新增验证码功能,默认关闭验证码 +2. `U` assets 目录用作本地文件上传,移到项目根目录 ### 0.3.11 diff --git a/app/api/cms/file.js b/app/api/cms/file.js index 76b2388..29533af 100644 --- a/app/api/cms/file.js +++ b/app/api/cms/file.js @@ -12,7 +12,7 @@ file.linPost('upload', '/', loginRequired, async ctx => { if (files.length < 1) { throw new ParametersException({ code: 10033 }); } - const uploader = new LocalUploader('app/assets'); + const uploader = new LocalUploader('assets'); const arr = await uploader.upload(files); ctx.json(arr); }); diff --git a/app/api/cms/user.js b/app/api/cms/user.js index a672a56..4dea2aa 100644 --- a/app/api/cms/user.js +++ b/app/api/cms/user.js @@ -14,6 +14,7 @@ import { import { UserIdentityModel } from '../../model/user'; import { logger } from '../../middleware/logger'; import { UserDao } from '../../dao/user'; +import { generateCaptcha } from '../../lib/captcha'; const user = new LinRouter({ prefix: '/cms/user', @@ -34,12 +35,16 @@ user.linPost( const v = await new RegisterValidator().validate(ctx); await userDao.createUser(v); if (config.getItem('socket.enable')) { - const username = v.get('body.username') - ctx.websocket.broadCast(JSON.stringify({ - name: username, - content: `管理员${ctx.currentUser.getDataValue('username')}新建了一个用户${username}`, - time: new Date() - })) + const username = v.get('body.username'); + ctx.websocket.broadCast( + JSON.stringify({ + name: username, + content: `管理员${ctx.currentUser.getDataValue( + 'username' + )}新建了一个用户${username}`, + time: new Date() + }) + ); } ctx.success({ code: 11 @@ -49,19 +54,27 @@ user.linPost( user.linPost('userLogin', '/login', user.permission('登录'), async ctx => { const v = await new LoginValidator().validate(ctx); - const user = await UserIdentityModel.verify( - v.get('body.username'), - v.get('body.password') - ); - const { accessToken, refreshToken } = getTokens({ - id: user.user_id - }); + const { accessToken, refreshToken } = await userDao.getTokens(v, ctx); ctx.json({ access_token: accessToken, refresh_token: refreshToken }); }); +user.linPost('userCaptcha', '/captcha', async ctx => { + let tag = null; + let image = null; + + if (config.getItem('loginCaptchaEnabled', false)) { + ({ tag, image } = await generateCaptcha()); + } + + ctx.json({ + tag, + image + }); +}); + user.linPut( 'userUpdate', '/', diff --git a/app/app.js b/app/app.js index 1b6a37c..01dd524 100644 --- a/app/app.js +++ b/app/app.js @@ -5,7 +5,7 @@ import mount from 'koa-mount'; import serve from 'koa-static'; import { config, json, logging, success, jwt, Loader } from 'lin-mizar'; import { PermissionModel } from './model/permission'; -import WebSocket from './extension/socket/socket' +import WebSocket from './extension/socket/socket'; /** * 首页 @@ -64,10 +64,10 @@ function applyDefaultExtends (app) { */ function applyWebSocket (app) { if (config.getItem('socket.enable')) { - const server = new WebSocket(app) - return server.init() + const server = new WebSocket(app); + return server.init(); } - return app + return app; } /** diff --git a/app/config/code-message.js b/app/config/code-message.js index 3eaff38..07169da 100644 --- a/app/config/code-message.js +++ b/app/config/code-message.js @@ -77,6 +77,7 @@ module.exports = { 10231: '无法分配不存在的权限', 10240: '书籍已存在', 10250: '请使用正确类型的令牌', - 10251: '请使用正确作用域的令牌' + 10251: '请使用正确作用域的令牌', + 10260: '请输入正确的验证码' } }; diff --git a/app/config/secure.js b/app/config/secure.js index 81aadaa..8f38815 100644 --- a/app/config/secure.js +++ b/app/config/secure.js @@ -12,7 +12,7 @@ module.exports = { timezone: '+08:00', define: { charset: 'utf8mb4' - }, + } }, secret: '\x88W\xf09\x91\x07\x98\x89\x87\x96\xa0A\xc68\xf9\xecJJU\x17\xc5V\xbe\x8b\xef\xd7\xd8\xd3\xe6\x95*4' // 发布生产环境前,请务必修改此默认秘钥 diff --git a/app/config/setting.js b/app/config/setting.js index bdc2fe3..42c9892 100644 --- a/app/config/setting.js +++ b/app/config/setting.js @@ -24,5 +24,7 @@ module.exports = { // // other config // limit: 2 // }, - } + }, + // 是否开启登录验证码 + loginCaptchaEnabled: false }; diff --git a/app/dao/admin.js b/app/dao/admin.js index 75aace2..8c648b7 100644 --- a/app/dao/admin.js +++ b/app/dao/admin.js @@ -107,11 +107,11 @@ class AdminDao { group_id: GroupLevel.Root, user_id: id } - }) + }); if (root) { throw new Forbidden({ code: 10079 - }) + }); } let transaction; try { diff --git a/app/dao/user.js b/app/dao/user.js index 9f4c474..2683ab1 100644 --- a/app/dao/user.js +++ b/app/dao/user.js @@ -1,4 +1,11 @@ -import { RepeatException, generate, NotFound, Forbidden } from 'lin-mizar'; +import { + RepeatException, + generate, + NotFound, + Forbidden, + config, + getTokens +} from 'lin-mizar'; import { UserModel, UserIdentityModel } from '../model/user'; import { UserGroupModel } from '../model/user-group'; import { GroupPermissionModel } from '../model/group-permission'; @@ -9,6 +16,7 @@ import sequelize from '../lib/db'; import { MountType, GroupLevel, IdentityType } from '../lib/type'; import { Op } from 'sequelize'; import { set, has, uniq } from 'lodash'; +import { verifyCaptcha } from '../lib/captcha'; class UserDao { async createUser (v) { @@ -50,6 +58,31 @@ class UserDao { await this.registerUser(v); } + async getTokens (v, ctx) { + if (config.getItem('loginCaptchaEnabled', false)) { + const tag = ctx.req.headers.tag; + const captcha = v.get('body.captcha'); + + if (!verifyCaptcha(captcha, tag)) { + throw new Forbidden({ + code: 10260 + }); + } + } + const user = await UserIdentityModel.verify( + v.get('body.username'), + v.get('body.password') + ); + const { accessToken, refreshToken } = getTokens({ + id: user.user_id + }); + + return { + accessToken, + refreshToken + }; + } + async updateUser (ctx, v) { const user = ctx.currentUser; if (v.get('body.username') && user.username !== v.get('body.username')) { diff --git a/app/extension/file/config.js b/app/extension/file/config.js index b1e4b71..6e108a9 100644 --- a/app/extension/file/config.js +++ b/app/extension/file/config.js @@ -2,7 +2,7 @@ module.exports = { file: { - storeDir: 'app/assets', + storeDir: 'assets', singleLimit: 1024 * 1024 * 2, totalLimit: 1024 * 1024 * 20, nums: 10, diff --git a/app/extension/socket/config.js b/app/extension/socket/config.js index 862971e..785db52 100644 --- a/app/extension/socket/config.js +++ b/app/extension/socket/config.js @@ -4,6 +4,6 @@ module.exports = { socket: { path: '/ws/message', enable: false, // 是否开启 websocket 模块 - intercept: false, // 是否开启 websocket 的鉴权拦截器 + intercept: false // 是否开启 websocket 的鉴权拦截器 } -}; \ No newline at end of file +}; diff --git a/app/extension/socket/socket.js b/app/extension/socket/socket.js index 7f23b06..fff3be9 100644 --- a/app/extension/socket/socket.js +++ b/app/extension/socket/socket.js @@ -1,80 +1,82 @@ -import http from 'http' -import Ws from 'ws' +import http from 'http'; +import Ws from 'ws'; import { config, jwt } from 'lin-mizar'; -import { URLSearchParams } from 'url' -import { set, get } from 'lodash' +import { URLSearchParams } from 'url'; +import { set, get } from 'lodash'; import { UserGroupModel } from '../../model/user-group'; -const USER_KEY = Symbol('user') +const USER_KEY = Symbol('user'); -const INTERCEPTORS = Symbol('WebSocket#interceptors') +const INTERCEPTORS = Symbol('WebSocket#interceptors'); -const HANDLE_CLOSE = Symbol('WebSocket#close') +const HANDLE_CLOSE = Symbol('WebSocket#close'); -const HANDLE_ERROR = Symbol('WebSocket#error') +const HANDLE_ERROR = Symbol('WebSocket#error'); class WebSocket { - constructor(app) { - this.app = app - this.wss = null - this.sessions = new Set() + constructor (app) { + this.app = app; + this.wss = null; + this.sessions = new Set(); } /** * 初始化,挂载 socket */ - init() { - const server = http.createServer(this.app.callback()) + init () { + const server = http.createServer(this.app.callback()); this.wss = new Ws.Server({ path: config.getItem('socket.path', '/ws/message'), noServer: true - }) + }); server.on('upgrade', this[INTERCEPTORS].bind(this)); - this.wss.on('connection', (socket) => { - socket.on('close', this[HANDLE_CLOSE].bind(this)) - socket.on('error', this[HANDLE_ERROR].bind(this)) - }) + this.wss.on('connection', socket => { + socket.on('close', this[HANDLE_CLOSE].bind(this)); + socket.on('error', this[HANDLE_ERROR].bind(this)); + }); - this.app.context.websocket = this - return server + this.app.context.websocket = this; + return server; } - [INTERCEPTORS](request, socket, head) { + [INTERCEPTORS] (request, socket, head) { // 是否开启 websocket 的鉴权拦截器 if (config.getItem('socket.intercept')) { - const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))) - const token = params.get('token') + const params = new URLSearchParams( + request.url.slice(request.url.indexOf('?')) + ); + const token = params.get('token'); try { - const { identity } = jwt.verifyToken(token) - this.wss.handleUpgrade(request, socket, head, (ws) => { - set(ws, USER_KEY, identity) - this.sessions.add(ws) + const { identity } = jwt.verifyToken(token); + this.wss.handleUpgrade(request, socket, head, ws => { + set(ws, USER_KEY, identity); + this.sessions.add(ws); this.wss.emit('connection', ws, request); - }) + }); } catch (error) { - console.log(error.message) - socket.destroy() + console.log(error.message); + socket.destroy(); } - return + return; } - this.wss.handleUpgrade(request, socket, head, (ws) => { - this.sessions.add(ws) + this.wss.handleUpgrade(request, socket, head, ws => { + this.sessions.add(ws); this.wss.emit('connection', ws, request); - }) + }); } - [HANDLE_CLOSE]() { + [HANDLE_CLOSE] () { for (const session of this.sessions) { if (session.readyState === Ws.CLOSED) { - this.sessions.delete(session) + this.sessions.delete(session); } } } - [HANDLE_ERROR](session, error) { - console.log(error) + [HANDLE_ERROR] (session, error) { + console.log(error); } /** @@ -83,14 +85,14 @@ class WebSocket { * @param {number} userId 用户id * @param {string} message 消息 */ - sendMessage(userId, message) { + sendMessage (userId, message) { for (const session of this.sessions) { if (session.readyState === Ws.OPEN) { - continue + continue; } if (get(session, USER_KEY) === userId) { - session.send(message) - break + session.send(message); + break; } } } @@ -100,47 +102,47 @@ class WebSocket { * * @param {WebSocket} session 当前会话 * @param {string} message 消息 - */ - sendMessageToSession(session, message) { - session.send(message) + */ + sendMessageToSession (session, message) { + session.send(message); } /** * 广播 - * - * @param {string} message 消息 + * + * @param {string} message 消息 */ - broadCast(message) { + broadCast (message) { this.sessions.forEach(session => { if (session.readyState === Ws.OPEN) { - session.send(message) + session.send(message); } - }) + }); } /** * 对某个分组广播 - * + * * @param {number} 分组id * @param {string} 消息 */ - async broadCastToGroup(groupId, message) { + async broadCastToGroup (groupId, message) { const userGroup = await UserGroupModel.findAll({ where: { group_id: groupId } - }) - const userIds = userGroup.map(v => v.getDataValue('user_id')) + }); + const userIds = userGroup.map(v => v.getDataValue('user_id')); for (const session of this.sessions) { if (session.readyState !== Ws.OPEN) { - continue + continue; } - const userId = get(session, USER_KEY) + const userId = get(session, USER_KEY); if (!userId) { - continue + continue; } if (userIds.includes(userId)) { - session.send(message) + session.send(message); } } } @@ -148,16 +150,16 @@ class WebSocket { /** * 获取所有会话 */ - getSessions() { - return this.sessions + getSessions () { + return this.sessions; } /** * 获得当前连接数 */ - getConnectionCount() { - return this.sessions.size + getConnectionCount () { + return this.sessions.size; } } -export default WebSocket \ No newline at end of file +export default WebSocket; diff --git a/app/lib/captcha.js b/app/lib/captcha.js new file mode 100644 index 0000000..046a210 --- /dev/null +++ b/app/lib/captcha.js @@ -0,0 +1,108 @@ +import sharp from 'sharp'; +import svgCaptcha from 'svg-captcha'; +import { + createCipheriv, + createDecipheriv, + randomBytes, + createHash +} from 'crypto'; +import { config } from 'lin-mizar'; + +const iv = Buffer.from(randomBytes(8)).toString('hex'); +const secret = config.getItem('secret'); +const key = createHash('sha256') + .update(String(secret)) + .digest('base64') + .substr(0, 32); + +/** + * 加密 + * + * @param {string} value 需要加密的信息 + * @returns 加密后的值 + */ +function aesEncrypt (value) { + const cipher = createCipheriv('aes-256-ctr', key, iv); + let encrypted = cipher.update(value, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return encrypted; +} + +/** + * 解密 + * + * @param {string} encrypted 需要解密的信息 + * @returns 解密后的值 + */ +function aesDecrypt (encrypted) { + const cipher = createDecipheriv('aes-256-ctr', key, iv); + let decrypted = cipher.update(encrypted, 'hex', 'utf8'); + decrypted += cipher.final('utf8'); + return decrypted; +} + +/** + * 给 tag 加密 + */ +function getTag (captcha) { + const date = new Date(); + // 5 分钟后过期 + date.setMinutes(date.getMinutes() + 5); + const info = { + captcha, + expired: date.getTime() + }; + + return aesEncrypt(JSON.stringify(info)); +} + +/** + * 校验验证码是否正确 + */ +function verifyCaptcha (loginCaptcha, tag) { + if (!loginCaptcha || !tag) { + return false; + } + const decrypted = aesDecrypt(tag); + try { + const { captcha, expired } = JSON.parse(decrypted); + // 大小写不敏感 + if ( + loginCaptcha.toLowerCase() !== captcha.toLowerCase() || + new Date().getTime() > expired + ) { + return false; + } + } catch (error) { + return false; + } + return true; +} + +/** + * 生成验证码图片及对称加密用到的 tag + */ +async function generateCaptcha () { + const captcha = svgCaptcha.create({ + size: 4, // 验证码长度 + fontSize: 45, // 验证码字号 + noise: Math.floor(Math.random() * 5), // 干扰线条数目 + width: 80, // 宽度 + height: 40, // 高度 + color: true, // 验证码字符是否有颜色,默认是没有,但是如果设置了背景颜色,那么默认就是有字符颜色 + background: '#fff' // 背景色 + }); + + const { data, text } = captcha; + const str = await sharp(Buffer.from(data)) + .png() + .toBuffer(); + const image = 'data:image/jpg;base64,' + str.toString('base64'); + + return { + image, + tag: getTag(text) + }; +} + +export { generateCaptcha, verifyCaptcha }; diff --git a/app/middleware/logger.js b/app/middleware/logger.js index cd97189..71865fb 100644 --- a/app/middleware/logger.js +++ b/app/middleware/logger.js @@ -113,4 +113,3 @@ function parseTemplate (template, user, response, request) { } return template; } - diff --git a/app/model/user.js b/app/model/user.js index e2fda55..6547593 100644 --- a/app/model/user.js +++ b/app/model/user.js @@ -113,9 +113,11 @@ class User extends Model { username: this.username, nickname: this.nickname, email: this.email, - avatar: this.avatar ? `${config.getItem('siteDomain', 'http://localhost')}/assets/${ - this.avatar - }` : '' + avatar: this.avatar + ? `${config.getItem('siteDomain', 'http://localhost')}/assets/${ + this.avatar + }` + : '' }; if (has(this, 'groups')) { return { ...origin, groups: get(this, 'groups', []) }; diff --git a/app/starter.js b/app/starter.js index 6bd97f5..8f1a9df 100644 --- a/app/starter.js +++ b/app/starter.js @@ -11,7 +11,7 @@ const { config } = require('lin-mizar/lin/config'); // if (files.length < 1) { // throw new Error('未找到符合条件的文件资源'); // } -// const uploader = new LocalUploader('app/assets'); +// const uploader = new LocalUploader('assets'); // const arr = await uploader.upload(files); // }); diff --git a/app/validator/user.js b/app/validator/user.js index f5555d0..0ed0d86 100644 --- a/app/validator/user.js +++ b/app/validator/user.js @@ -1,4 +1,4 @@ -import { LinValidator, Rule } from 'lin-mizar'; +import { config, LinValidator, Rule } from 'lin-mizar'; import { isOptional } from '../lib/util'; import validator from 'validator'; @@ -60,6 +60,10 @@ class LoginValidator extends LinValidator { super(); this.username = new Rule('isNotEmpty', '用户名不可为空'); this.password = new Rule('isNotEmpty', '密码不可为空'); + + if (config.getItem('loginCaptchaEnabled', false)) { + this.captcha = new Rule('isNotEmpty', '验证码不能为空'); + } } } diff --git a/jest.config.js b/jest.config.js index 611c7b0..d2caf42 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,14 +2,13 @@ // https://jestjs.io/docs/en/configuration.html module.exports = { - coverageDirectory: 'coverage', testEnvironment: 'node', + coverageDirectory: 'coverage', testPathIgnorePatterns: ['/node_modules/'], testMatch: [ '**/?(*.)(spec).js?(x)' - // '**/?(*.)(spec|test).js?(x)' ], transform: { - "^.+\\.[t|j]sx?$": "babel-jest" - }, + '^.+\\.[t|j]sx?$': 'babel-jest' + } }; diff --git a/package.json b/package.json index b44940c..431113b 100644 --- a/package.json +++ b/package.json @@ -48,14 +48,17 @@ }, "dependencies": { "@koa/cors": "^2.2.3", + "crypto": "^1.0.1", "koa": "^2.7.0", "koa-bodyparser": "^4.2.1", "koa-mount": "^4.0.0", "koa-static": "^5.0.0", - "lin-mizar": "^0.3.8", + "lin-mizar": "^0.3.9", "mysql2": "^2.1.0", "sequelize": "^5.21.13", - "validator": "^13.1.1", + "sharp": "^0.29.0", + "svg-captcha": "^1.4.0", + "validator": "^13.7.0", "ws": "^7.4.4" } }