@sisin/egg-client 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +267 -0
- package/app/constants/code.js +9 -0
- package/app/constants/crypt.js +10 -0
- package/app/constants/error.js +20 -0
- package/app/constants/redis.js +17 -0
- package/app/controller/authSystem/auth.js +119 -0
- package/app/controller/common/health.js +15 -0
- package/app/controller/home.js +10 -0
- package/app/core/database/database-health.js +39 -0
- package/app/core/database/database-manager.js +43 -0
- package/app/core/database/index.js +19 -0
- package/app/core/database/prisma-logging.js +38 -0
- package/app/core/database/transaction-manager.js +9 -0
- package/app/core/repository/auth/user_repository.js +14 -0
- package/app/core/repository/base-repository.js +26 -0
- package/app/extend/context.js +57 -0
- package/app/extend/helper.js +24 -0
- package/app/middleware/auth.js +16 -0
- package/app/middleware/check_ready.js +11 -0
- package/app/middleware/error_handler.js +28 -0
- package/app/middleware/jwt_auth.js +27 -0
- package/app/middleware/login_limit.js +4 -0
- package/app/middleware/permission.js +8 -0
- package/app/middleware/request_log.js +28 -0
- package/app/middleware/upload_limit.js +17 -0
- package/app/model/redis/redis-auth.js +42 -0
- package/app/redis/index.js +25 -0
- package/app/redis/redis-manager.js +46 -0
- package/app/router/auth.js +17 -0
- package/app/router/common.js +11 -0
- package/app/router/index.js +13 -0
- package/app/router.js +11 -0
- package/app/service/authSystem/auth.js +144 -0
- package/app/service/authSystem/permission.js +4 -0
- package/app/service/authSystem/token.js +4 -0
- package/app/service/redis/auth.js +4 -0
- package/app/utils/common.js +58 -0
- package/app/utils/encrypt.js +33 -0
- package/app/utils/jwt.js +23 -0
- package/app/utils/logger.js +209 -0
- package/app/utils/permission.js +2 -0
- package/app/utils/prisma-manager.js +127 -0
- package/app/utils/prisma.js +21 -0
- package/app/validate/user.js +6 -0
- package/app.js +21 -0
- package/config/config.default.js +137 -0
- package/config/logging.js +26 -0
- package/config/plugin.default.js +3 -0
- package/index.js +22 -0
- package/init/index.js +39 -0
- package/init/init-auth.js +37 -0
- package/init/init-database.js +59 -0
- package/init/init-logger.js +18 -0
- package/init/init-redis.js +26 -0
- package/init/init-websocket.js +10 -0
- package/init/ready.js +8 -0
- package/package.json +60 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// 统一返回格式 & 参数注入 & 数据库访问
|
|
2
|
+
const { SUCCESS, SERVER_ERROR } = require('../constants/code');
|
|
3
|
+
const BaseRepository = require('../core/repository/base-repository');
|
|
4
|
+
const TransactionManager = require('../core/database/transaction-manager');
|
|
5
|
+
const encrypt = require('../utils/encrypt');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
/**
|
|
9
|
+
* 统一 API 响应
|
|
10
|
+
*/
|
|
11
|
+
get api() {
|
|
12
|
+
const ctx = this;
|
|
13
|
+
return {
|
|
14
|
+
success(data = null, msg = '操作成功', code = SUCCESS) {
|
|
15
|
+
ctx.body = { code, success: true, msg, data };
|
|
16
|
+
},
|
|
17
|
+
fail(msg = '操作失败', code = SERVER_ERROR, data = null) {
|
|
18
|
+
ctx.body = { code, success: false, msg, data };
|
|
19
|
+
},
|
|
20
|
+
page(list = [], total = 0, msg = '查询成功', code = SUCCESS) {
|
|
21
|
+
ctx.body = { code, success: true, msg, data: { list, total } };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 统一参数注入(合并 body + query + params)
|
|
28
|
+
*/
|
|
29
|
+
get payload() {
|
|
30
|
+
return { ...this.request.body, ...this.request.query, ...this.params };
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 统一数据库访问
|
|
35
|
+
* 使用: ctx.repository.fx.Raw('user_info').findOne({ where: { id: 1 } })
|
|
36
|
+
* 事务: ctx.repository.fx.transaction(async (db) => { await db.Raw('user_info').create({...}) })
|
|
37
|
+
*/
|
|
38
|
+
get repository() {
|
|
39
|
+
if (!this._repository) {
|
|
40
|
+
this._repository = {};
|
|
41
|
+
const Dbs = this.Dbs || {};
|
|
42
|
+
for (const key in Dbs) {
|
|
43
|
+
if (!Dbs[key]) continue;
|
|
44
|
+
this._repository[key] = {
|
|
45
|
+
Raw(modelName) { return new BaseRepository(Dbs[key][modelName]); },
|
|
46
|
+
transaction(callback) { return TransactionManager.execute(Dbs[key], async (tx) => {
|
|
47
|
+
const db = { Raw(modelName) { return new BaseRepository(tx[modelName]); } };
|
|
48
|
+
return await callback(db);
|
|
49
|
+
}); },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return this._repository;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
get encrypt() { return encrypt; },
|
|
57
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 视图渲染、接口返回、业务常用格式化等函数
|
|
2
|
+
const dayjs = require('dayjs');
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
formatTime(time = new Date()) {
|
|
6
|
+
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
|
|
7
|
+
},
|
|
8
|
+
formDateExp(start, end = new Date()) {
|
|
9
|
+
if (!start || !end) return false;
|
|
10
|
+
return dayjs(end).isAfter(dayjs(start));
|
|
11
|
+
},
|
|
12
|
+
timeUtil() {
|
|
13
|
+
return {
|
|
14
|
+
now: () => Date.now(),
|
|
15
|
+
addMs: (ms) => Date.now() + ms,
|
|
16
|
+
addSec: (s) => Date.now() + s * 1000,
|
|
17
|
+
addMin: (m) => Date.now() + m * 60 * 1000,
|
|
18
|
+
addHour: (h) => Date.now() + h * 3600 * 1000,
|
|
19
|
+
addDay: (d) => Date.now() + d * 86400 * 1000,
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
snakeToCamel(str) { return str.replace(/_(\w)/g, (_, letter) => letter.toUpperCase()); },
|
|
23
|
+
camelToSnake(str) { return str.replace(/[A-Z]/g, (match) => '_' + match.toLowerCase()); },
|
|
24
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// JWT 认证(旧版,兼容)
|
|
2
|
+
module.exports = () => {
|
|
3
|
+
return async (ctx, next) => {
|
|
4
|
+
const { whitelists = [] } = ctx.app.config.auth;
|
|
5
|
+
if (whitelists.includes(ctx.request.path)) { return next(); }
|
|
6
|
+
const token = ctx.get('Authorization');
|
|
7
|
+
if (!token) { ctx.status = 401; return ctx.api.fail('未登录', 401, null); }
|
|
8
|
+
try {
|
|
9
|
+
ctx.user = ctx.app.jwt.verify(token.replace('Bearer ', ''));
|
|
10
|
+
await next();
|
|
11
|
+
} catch (e) {
|
|
12
|
+
ctx.status = 401;
|
|
13
|
+
return ctx.api.fail('用户未登录或登录已过期', 401, null);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// 全局错误处理中间件
|
|
2
|
+
const errorCode = require('../constants/error');
|
|
3
|
+
|
|
4
|
+
module.exports = () => {
|
|
5
|
+
return async function errorHandler(ctx, next) {
|
|
6
|
+
try {
|
|
7
|
+
await next();
|
|
8
|
+
if (ctx.status === 404 && !ctx.body) {
|
|
9
|
+
ctx.status = 404;
|
|
10
|
+
ctx.api.fail(errorCode[404].msg, 404, null);
|
|
11
|
+
}
|
|
12
|
+
} catch (err) {
|
|
13
|
+
ctx.status = err.status || 500;
|
|
14
|
+
ctx.logger.error(err);
|
|
15
|
+
if (err.code) {
|
|
16
|
+
if (err.code === 'invalid_param') {
|
|
17
|
+
ctx.status = 200;
|
|
18
|
+
const errors = err.errors[0];
|
|
19
|
+
ctx.api.fail(errors.field + ' ' + errors.message, 422, null);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
ctx.api.fail(err.msg || err.message, err.code, err.data || null);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ctx.api.fail(err.message || errorCode[500].msg, 500, null);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// JWT 认证中间件
|
|
2
|
+
const errorCode = require('../constants/error');
|
|
3
|
+
|
|
4
|
+
module.exports = () => {
|
|
5
|
+
return async (ctx, next) => {
|
|
6
|
+
const { allowDevice, whiteList } = ctx.app.config.auth;
|
|
7
|
+
|
|
8
|
+
// 白名单(前缀匹配)跳过 JWT 校验
|
|
9
|
+
if (whiteList.some((prefix) => ctx.request.path.startsWith(prefix))) {
|
|
10
|
+
await next();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 获取 accessToken
|
|
15
|
+
let token = ctx.get('Authorization');
|
|
16
|
+
if (!token) { ctx.api.fail(errorCode[401]?.msg, 401, null); return; }
|
|
17
|
+
token = token.replace('Bearer ', '');
|
|
18
|
+
|
|
19
|
+
// 调用 service 验证 accessToken
|
|
20
|
+
const result = await ctx.service.authSystem.auth.verifyAccessToken({ token });
|
|
21
|
+
if (!result.ok) { ctx.api.fail(errorCode[result.code]?.msg, result.code, null); return; }
|
|
22
|
+
|
|
23
|
+
// 挂载用户信息
|
|
24
|
+
ctx.user = { uid: result.payload.uid, sid: result.payload.sid, jti: result.payload.jti, type: result.payload.type };
|
|
25
|
+
await next();
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// 权限中间件(预留,由业务项目扩展)
|
|
2
|
+
module.exports = (permissionCode) => {
|
|
3
|
+
return async (ctx, next) => {
|
|
4
|
+
const permissions = ctx.user.permissions || [];
|
|
5
|
+
if (!permissions.includes(permissionCode)) { return ctx.api.fail('No permission', 403, null); }
|
|
6
|
+
await next();
|
|
7
|
+
};
|
|
8
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { requestLogger } = require('../utils/logger');
|
|
2
|
+
|
|
3
|
+
// 请求日志中间件
|
|
4
|
+
module.exports = () => {
|
|
5
|
+
return async (ctx, next) => {
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
const { method, url, ip } = ctx;
|
|
8
|
+
|
|
9
|
+
const matched = ctx.app.router.match(ctx.path, ctx.method);
|
|
10
|
+
if (!matched || !matched.route) {
|
|
11
|
+
ctx.status = 404;
|
|
12
|
+
ctx.api.fail('接口不存在', 404, null);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try { await next(); } catch (err) {
|
|
17
|
+
const duration = Date.now() - start;
|
|
18
|
+
requestLogger.error(method + ' ' + url + ' ' + (ctx.status || 500) + ' ' + duration + 'ms', { type: 'error', method, url, ip, status: ctx.status || 500, duration, message: err.message, stack: err.stack, code: err.code || '' });
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const duration = Date.now() - start;
|
|
23
|
+
const status = ctx.status || 200;
|
|
24
|
+
if (status >= 500) requestLogger.error(method + ' ' + url + ' ' + status + ' ' + duration + 'ms', { type: 'response', method, url, ip, status, duration });
|
|
25
|
+
else if (status >= 400) requestLogger.warn(method + ' ' + url + ' ' + status + ' ' + duration + 'ms', { type: 'response', method, url, ip, status, duration });
|
|
26
|
+
else requestLogger.info(method + ' ' + url + ' ' + status + ' ' + duration + 'ms', { type: 'response', method, url, ip, status, duration });
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// 上传文件限制中间件(大小、类型)
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { BAD_REQUEST } = require('../constants/code');
|
|
5
|
+
|
|
6
|
+
module.exports = () => {
|
|
7
|
+
return async function uploadLimit(ctx, next) {
|
|
8
|
+
const file = ctx.request.files?.[0];
|
|
9
|
+
if (!file) { ctx.api.fail('请上传文件', BAD_REQUEST, null); return; }
|
|
10
|
+
const { whiteList = [], size = 10 } = ctx.app.config.fileLimit;
|
|
11
|
+
const ext = path.extname(file.filename);
|
|
12
|
+
if (!whiteList.includes(ext)) { ctx.api.fail('文件格式错误', BAD_REQUEST, null); return; }
|
|
13
|
+
const maxSize = size * 1024 * 1024;
|
|
14
|
+
if (file.size > maxSize) { ctx.api.fail('文件大小超出限制', BAD_REQUEST, null); return; }
|
|
15
|
+
await next();
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// 用户 token 缓存 CRUD(Session 模式)
|
|
2
|
+
const { AUTH_SESSION, AUTH_USER_SESSIONS, AUTH_REFRESH_LOCK } = require('../../constants/redis');
|
|
3
|
+
|
|
4
|
+
class RedisAuth {
|
|
5
|
+
constructor(app, clientName = 'fx') {
|
|
6
|
+
this.app = app;
|
|
7
|
+
this.clientName = clientName;
|
|
8
|
+
this.redis = this.app.redis.getClient(this.clientName);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
_client() { return this.app.redis.getClient(this.clientName); }
|
|
12
|
+
|
|
13
|
+
// ==================== Session ====================
|
|
14
|
+
async setAuthSession(sid, data, ttl) {
|
|
15
|
+
await this.redis.set('auth:session:' + sid, JSON.stringify(data), 'EX', ttl);
|
|
16
|
+
}
|
|
17
|
+
async getAuthSession(sid) {
|
|
18
|
+
const raw = await this.redis.get('auth:session:' + sid);
|
|
19
|
+
return raw ? JSON.parse(raw) : null;
|
|
20
|
+
}
|
|
21
|
+
async delAuthSession(sid) { await this.redis.del('auth:session:' + sid); }
|
|
22
|
+
async expireAuthSession(sid, ttl) { await this.redis.expire('auth:session:' + sid, ttl); }
|
|
23
|
+
|
|
24
|
+
// ==================== Session Index ====================
|
|
25
|
+
async addUserSession(uid, sid) { await this.redis.sadd('auth:user:' + uid + ':sessions', sid); }
|
|
26
|
+
async removeUserSession(uid, sid) { await this.redis.srem('auth:user:' + uid + ':sessions', sid); }
|
|
27
|
+
async getUserSessions(uid) { return await this.redis.smembers('auth:user:' + uid + ':sessions'); }
|
|
28
|
+
async clearUserSessions(uid) { await this.redis.del('auth:user:' + uid + ':sessions'); }
|
|
29
|
+
|
|
30
|
+
// ==================== User Cache ====================
|
|
31
|
+
async setUserCache(userId, userInfo, ttl) { await this.redis.set('user:login:' + userId, userInfo, 'EX', ttl * 60); }
|
|
32
|
+
async getUserCache(userId) { return await this.redis.get('user:login:' + userId); }
|
|
33
|
+
|
|
34
|
+
// ==================== Lock ====================
|
|
35
|
+
async acquireRefreshLock(sid, ttl = 5) {
|
|
36
|
+
const result = await this.redis.set('auth:refresh:lock:' + sid, '1', 'NX', 'EX', ttl);
|
|
37
|
+
return result === 'OK';
|
|
38
|
+
}
|
|
39
|
+
async releaseRefreshLock(sid) { await this.redis.del('auth:refresh:lock:' + sid); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = RedisAuth;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Redis 客户端创建
|
|
2
|
+
const Redis = require('ioredis');
|
|
3
|
+
const logger = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
module.exports = (app) => {
|
|
6
|
+
const clients = {};
|
|
7
|
+
const configs = app.config.redis;
|
|
8
|
+
for (const name in configs) {
|
|
9
|
+
const config = configs[name];
|
|
10
|
+
const redis = new Redis({
|
|
11
|
+
host: config.host, port: config.port, password: config.password, db: config.db,
|
|
12
|
+
lazyConnect: true,
|
|
13
|
+
maxRetriesPerRequest: config?.maxRetriesPerRequest || 3,
|
|
14
|
+
enableOfflineQueue: false,
|
|
15
|
+
connectTimeout: config?.connectTimeout || 10000,
|
|
16
|
+
retryStrategy(times) { const delay = Math.min(times * 100, 3000); logger.warning('[Redis:' + name + '] reconnecting ' + times); return delay; },
|
|
17
|
+
reconnectOnError(err) { if (err.message.includes('READONLY')) return true; return false; },
|
|
18
|
+
});
|
|
19
|
+
redis.on('ready', () => logger.success('[Redis:' + name + '] 已就绪'));
|
|
20
|
+
redis.on('error', (err) => logger.error('[Redis:' + name + '] 错误', err));
|
|
21
|
+
redis.on('close', () => logger.warning('[Redis:' + name + '] 连接关闭'));
|
|
22
|
+
clients[name] = redis;
|
|
23
|
+
}
|
|
24
|
+
return clients;
|
|
25
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class RedisManager {
|
|
2
|
+
constructor(clients) { this.clients = clients; }
|
|
3
|
+
|
|
4
|
+
getClient(name = 'fx') {
|
|
5
|
+
const client = this.clients[name];
|
|
6
|
+
if (!client) throw new Error('Redis client "' + name + '" not found');
|
|
7
|
+
return client;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async set(clientName, key, value, expire = null) {
|
|
11
|
+
const client = this.getClient(clientName);
|
|
12
|
+
const data = typeof value === 'string' ? value : JSON.stringify(value);
|
|
13
|
+
return expire ? await client.set(key, data, 'EX', expire) : await client.set(key, data);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async hset(clientName, key, field, value) {
|
|
17
|
+
const client = this.getClient(clientName);
|
|
18
|
+
const data = typeof value === 'string' ? value : JSON.stringify(value);
|
|
19
|
+
return await client.hset(key, field, data);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async get(clientName, key) {
|
|
23
|
+
const client = this.getClient(clientName);
|
|
24
|
+
const data = await client.get(key);
|
|
25
|
+
try { return JSON.parse(data); } catch { return data; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async hget(clientName, key, field) {
|
|
29
|
+
const client = this.getClient(clientName);
|
|
30
|
+
const data = await client.hget(key, field);
|
|
31
|
+
try { return JSON.parse(data); } catch { return data; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async hgetall(clientName, key, parse = false) {
|
|
35
|
+
const client = this.getClient(clientName);
|
|
36
|
+
const data = await client.hgetall(key);
|
|
37
|
+
if (!parse) return data;
|
|
38
|
+
const result = {};
|
|
39
|
+
for (const field in data) { try { result[field] = JSON.parse(data[field]); } catch { result[field] = data[field]; } }
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async del(clientName, key) { return await this.getClient(clientName).del(key); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = RedisManager;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 认证路由
|
|
3
|
+
*
|
|
4
|
+
* POST /api/public/auth/login - 用户登录
|
|
5
|
+
* POST /api/public/auth/refresh - 刷新 accessToken
|
|
6
|
+
* POST /api/auth/logout - 登出(需鉴权)
|
|
7
|
+
* POST /api/auth/changePassword - 修改密码(需鉴权)
|
|
8
|
+
* GET /api/auth/getUserInfo - 获取用户信息
|
|
9
|
+
*/
|
|
10
|
+
module.exports = (app) => {
|
|
11
|
+
const { router, controller } = app;
|
|
12
|
+
router.post("/public/auth/login", controller.authSystem.auth.login);
|
|
13
|
+
router.post("/public/auth/refresh", controller.authSystem.auth.refresh);
|
|
14
|
+
router.post("/auth/logout", controller.authSystem.auth.logout);
|
|
15
|
+
router.post("/auth/changePassword", controller.authSystem.auth.changePassword);
|
|
16
|
+
router.get("/auth/getUserInfo", controller.authSystem.auth.getUserInfo);
|
|
17
|
+
};
|
package/app/router.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const Service = require('egg').Service;
|
|
2
|
+
const { comparePassword, encryptPassword } = require('../../utils/encrypt');
|
|
3
|
+
const { createToken, verifyToken } = require('../../utils/jwt');
|
|
4
|
+
const { TOKEN_EXPIRES_IN, REFRESH_TOKEN_EXPIRES_IN, ACCESS_SECRET, REFRESH_SECRET, SESSION_EXPIRES_IN } = require('../../constants/crypt');
|
|
5
|
+
const { randomUUID } = require('crypto');
|
|
6
|
+
|
|
7
|
+
class AuthService extends Service {
|
|
8
|
+
|
|
9
|
+
async login({ username, password }) {
|
|
10
|
+
const { ctx, app } = this;
|
|
11
|
+
const user = await this.findByUser({ username });
|
|
12
|
+
if (!user) return { ok: false, code: 40105 };
|
|
13
|
+
if (user.status !== 1) return { ok: false, code: 40301 };
|
|
14
|
+
|
|
15
|
+
const valid = await comparePassword(password, user.pwd);
|
|
16
|
+
if (!valid) return { ok: false, code: 40106 };
|
|
17
|
+
|
|
18
|
+
const sid = randomUUID();
|
|
19
|
+
const accessJti = randomUUID();
|
|
20
|
+
const refreshJti = randomUUID();
|
|
21
|
+
const now = Math.floor(Date.now() / 1000);
|
|
22
|
+
|
|
23
|
+
const accessToken = createToken({ uid: user.id, sid, jti: accessJti, type: 'access', iat: now }, ACCESS_SECRET, { expiresIn: TOKEN_EXPIRES_IN + 'm' });
|
|
24
|
+
const refreshToken = createToken({ uid: user.id, sid, jti: refreshJti, type: 'refresh', iat: now }, REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN + 'm' });
|
|
25
|
+
|
|
26
|
+
const sessionTtl = SESSION_EXPIRES_IN * 60;
|
|
27
|
+
const redis = app.modle.redis.redisAuth;
|
|
28
|
+
await redis.setAuthSession(sid, { uid: user.id, refreshJti, platform: 'unknown', iat: now, lastRefreshTime: now }, sessionTtl);
|
|
29
|
+
await redis.addUserSession(user.id, sid);
|
|
30
|
+
await redis.setUserCache(user.id, JSON.stringify({ userId: user.id, enName: user.en_name, roles: [], status: user.status }), REFRESH_TOKEN_EXPIRES_IN);
|
|
31
|
+
|
|
32
|
+
return { ok: true, data: { accessToken, refreshToken, sid } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async verifyAccessToken({ token }) {
|
|
36
|
+
const { app } = this;
|
|
37
|
+
let payload;
|
|
38
|
+
try { payload = verifyToken(token, ACCESS_SECRET); }
|
|
39
|
+
catch (err) { return { ok: false, code: err.name === 'TokenExpiredError' ? 40101 : 40103 }; }
|
|
40
|
+
if (!payload || payload.type !== 'access') return { ok: false, code: 40103 };
|
|
41
|
+
const session = await app.modle.redis.redisAuth.getAuthSession(payload.sid);
|
|
42
|
+
if (!session) return { ok: false, code: 40104 };
|
|
43
|
+
return { ok: true, payload };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async refreshToken({ token }) {
|
|
47
|
+
const { ctx, app } = this;
|
|
48
|
+
let payload;
|
|
49
|
+
try { payload = verifyToken(token, REFRESH_SECRET); }
|
|
50
|
+
catch (err) { return { ok: false, code: err.name === 'TokenExpiredError' ? 40102 : 40103 }; }
|
|
51
|
+
if (payload.type !== 'refresh') return { ok: false, code: 40103 };
|
|
52
|
+
const { uid, sid, jti } = payload;
|
|
53
|
+
const redis = app.modle.redis.redisAuth;
|
|
54
|
+
const session = await redis.getAuthSession(sid);
|
|
55
|
+
if (!session) return { ok: false, code: 40104 };
|
|
56
|
+
if (session.refreshJti !== jti) return { ok: false, code: 401 };
|
|
57
|
+
|
|
58
|
+
const locked = await redis.acquireRefreshLock(sid);
|
|
59
|
+
if (!locked) return { ok: false, code: 401 };
|
|
60
|
+
try {
|
|
61
|
+
const sessionAfterLock = await redis.getAuthSession(sid);
|
|
62
|
+
if (!sessionAfterLock || sessionAfterLock.refreshJti !== jti) return { ok: false, code: 401 };
|
|
63
|
+
const now = Math.floor(Date.now() / 1000);
|
|
64
|
+
const newRefreshJti = randomUUID();
|
|
65
|
+
const newAccessJti = randomUUID();
|
|
66
|
+
const newAccessToken = createToken({ uid, sid, jti: newAccessJti, type: 'access', iat: now }, ACCESS_SECRET, { expiresIn: TOKEN_EXPIRES_IN + 'm' });
|
|
67
|
+
const newRefreshToken = createToken({ uid, sid, jti: newRefreshJti, type: 'refresh', iat: now }, REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN + 'm' });
|
|
68
|
+
sessionAfterLock.refreshJti = newRefreshJti;
|
|
69
|
+
sessionAfterLock.lastRefreshTime = now;
|
|
70
|
+
await redis.setAuthSession(sid, sessionAfterLock, SESSION_EXPIRES_IN * 60);
|
|
71
|
+
return { ok: true, data: { accessToken: newAccessToken, refreshToken: newRefreshToken } };
|
|
72
|
+
} finally { await redis.releaseRefreshLock(sid); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async logout({ uid, sid }) {
|
|
76
|
+
const { app } = this;
|
|
77
|
+
const redis = app.modle.redis.redisAuth;
|
|
78
|
+
await redis.delAuthSession(sid);
|
|
79
|
+
await redis.removeUserSession(uid, sid);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async kickout({ uid }) {
|
|
83
|
+
const { app } = this;
|
|
84
|
+
const redis = app.modle.redis.redisAuth;
|
|
85
|
+
const { AUTH_SESSION, AUTH_USER_SESSIONS } = require('../../constants/redis');
|
|
86
|
+
const sids = await redis.getUserSessions(uid);
|
|
87
|
+
const pipeline = redis._client().pipeline();
|
|
88
|
+
for (const sid of sids) pipeline.del(AUTH_SESSION + ':' + sid);
|
|
89
|
+
pipeline.del(AUTH_USER_SESSIONS + ':' + uid);
|
|
90
|
+
await pipeline.exec();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async changePassword({ uid, oldPassword, newPassword }) {
|
|
94
|
+
const { ctx, app } = this;
|
|
95
|
+
const { Raw } = ctx.repository.fx;
|
|
96
|
+
const user = await Raw('user_info').findOne({ where: { id: uid } });
|
|
97
|
+
if (!user) return { ok: false, code: 40105 };
|
|
98
|
+
const valid = await comparePassword(oldPassword, user.pwd);
|
|
99
|
+
if (!valid) return { ok: false, code: 40106 };
|
|
100
|
+
const newHash = encryptPassword(newPassword);
|
|
101
|
+
await Raw('user_info').updateByFilter({ where: { id: uid }, data: { pwd: newHash, update_time: new Date() } });
|
|
102
|
+
await this.kickout({ uid });
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async refreshUserCache({ id }) {
|
|
107
|
+
if (!id) return null;
|
|
108
|
+
const { ctx, app } = this;
|
|
109
|
+
const { Raw } = ctx.repository.fx;
|
|
110
|
+
const user = await Raw('user_info').findOne({ where: { id } });
|
|
111
|
+
if (!user) return null;
|
|
112
|
+
const payload = { userId: id, enName: user.en_name, roles: [], status: user.status };
|
|
113
|
+
await app.modle.redis.redisAuth.setUserCache(id, JSON.stringify(payload), TOKEN_EXPIRES_IN);
|
|
114
|
+
return user;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async findByUser({ username }) {
|
|
118
|
+
const { ctx } = this;
|
|
119
|
+
const { Raw } = ctx.repository.fx;
|
|
120
|
+
return await Raw('user_info').findOne({ where: { en_name: username } });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async findMany(query = {}) {
|
|
124
|
+
const { ctx } = this;
|
|
125
|
+
const { Raw } = ctx.repository.fx;
|
|
126
|
+
return await Raw('user_info').findMany(query);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async updateUserMany(user) {
|
|
130
|
+
const { ctx } = this;
|
|
131
|
+
if (!Array.isArray(user)) user = [user];
|
|
132
|
+
const { transaction } = ctx.repository.fx;
|
|
133
|
+
const dateTime = new Date();
|
|
134
|
+
await transaction(async (db) => {
|
|
135
|
+
for (const o of user) {
|
|
136
|
+
const { id, ...data } = o;
|
|
137
|
+
if (!id) continue;
|
|
138
|
+
await db.Raw('user_info').updateByFilter({ where: { id }, data: { ...data, update_time: dateTime } });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = AuthService;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const dayjs = require("dayjs");
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
/**
|
|
5
|
+
* 格式化时间为 YYYY-MM-DD HH:mm:ss
|
|
6
|
+
* @param {Date|string|number} [time=new Date()]
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
formatTime(time = new Date()) {
|
|
10
|
+
return dayjs(time).format("YYYY-MM-DD HH:mm:ss");
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 判断结束时间是否大于开始时间(true=未过期)
|
|
15
|
+
* @param {Date|string} start
|
|
16
|
+
* @param {Date|string} [end=new Date()]
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
formDateExp(start, end = new Date()) {
|
|
20
|
+
if (!start || !end) return false;
|
|
21
|
+
return dayjs(end).isAfter(dayjs(start));
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 时间工具函数
|
|
26
|
+
* @returns {{ now: function, addMs: function, addSec: function, addMin: function, addHour: function, addDay: function }}
|
|
27
|
+
*/
|
|
28
|
+
timeUtil() {
|
|
29
|
+
return {
|
|
30
|
+
now: () => Date.now(),
|
|
31
|
+
addMs: (ms) => Date.now() + ms,
|
|
32
|
+
addSec: (s) => Date.now() + s * 1000,
|
|
33
|
+
addMin: (m) => Date.now() + m * 60 * 1000,
|
|
34
|
+
addHour: (h) => Date.now() + h * 3600 * 1000,
|
|
35
|
+
addDay: (d) => Date.now() + d * 86400 * 1000,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 下划线命名转驼峰命名
|
|
41
|
+
* @param {string} str
|
|
42
|
+
* @returns {string}
|
|
43
|
+
* @example snakeToCamel("user_name") => "userName"
|
|
44
|
+
*/
|
|
45
|
+
snakeToCamel(str) {
|
|
46
|
+
return str.replace(/_(\w)/g, (_, letter) => letter.toUpperCase());
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 驼峰命名转下划线命名
|
|
51
|
+
* @param {string} str
|
|
52
|
+
* @returns {string}
|
|
53
|
+
* @example camelToSnake("userName") => "user_name"
|
|
54
|
+
*/
|
|
55
|
+
camelToSnake(str) {
|
|
56
|
+
return str.replace(/[A-Z]/g, (match) => "_" + match.toLowerCase());
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// 加密 用户密码加密-不可逆哈希
|
|
2
|
+
const bcrypt = require('bcryptjs');
|
|
3
|
+
const SALT_ROUNDS = 10;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 密码加密
|
|
7
|
+
* @param {string} password 明文密码
|
|
8
|
+
* @return {string} 密文
|
|
9
|
+
*/
|
|
10
|
+
function encryptPassword(password) {
|
|
11
|
+
if (isEncrypted(password)) return password;
|
|
12
|
+
return bcrypt.hashSync(password, SALT_ROUNDS);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 校验密码
|
|
17
|
+
* @param {string} password 明文
|
|
18
|
+
* @param {string} hash 密文
|
|
19
|
+
* @return {boolean} 是否匹配
|
|
20
|
+
*/
|
|
21
|
+
function comparePassword(password, hash) {
|
|
22
|
+
return bcrypt.compare(password, hash);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 判断是否已加密
|
|
27
|
+
* @param {string} password 密码
|
|
28
|
+
*/
|
|
29
|
+
function isEncrypted(password) {
|
|
30
|
+
return /^\[aby]\$\d{2}\$/.test(password);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { encryptPassword, comparePassword, isEncrypted };
|