feat: implement P0 security and reliability improvements
- bcrypt password hashing with auto-migration from SHA-256 - BLE command retry (3 attempts, 500ms delay, skip on disconnect) - BLE auto-reconnect with service re-discovery on disconnect - Treatment page disconnect/reconnect event handling - Token refresh endpoint with 3-day grace period - Client-side token auto-refresh when <24h remaining - Single treatment record detail API with ownership check
这个提交包含在:
@@ -40,6 +40,7 @@ App({
|
|||||||
success: function (res) {
|
success: function (res) {
|
||||||
http.post('/api/v1/auth/login', { code: res.code }).then(function (data) {
|
http.post('/api/v1/auth/login', { code: res.code }).then(function (data) {
|
||||||
wx.setStorageSync('token', data.token)
|
wx.setStorageSync('token', data.token)
|
||||||
|
wx.setStorageSync('token_expiry', String(Math.floor(Date.now() / 1000) + (data.expires_in || 604800)))
|
||||||
var app = getApp()
|
var app = getApp()
|
||||||
app.globalData.userInfo = data.user_info
|
app.globalData.userInfo = data.user_info
|
||||||
app.globalData.userId = data.user_id
|
app.globalData.userId = data.user_id
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ Page({
|
|||||||
this._onStatus = this.onStatus.bind(this)
|
this._onStatus = this.onStatus.bind(this)
|
||||||
this._onComplete = this.onComplete.bind(this)
|
this._onComplete = this.onComplete.bind(this)
|
||||||
this._onException = this.onException.bind(this)
|
this._onException = this.onException.bind(this)
|
||||||
|
this._onDisconnect = this.onDisconnect.bind(this)
|
||||||
|
this._onReconnect = this.onReconnect.bind(this)
|
||||||
|
this._onReconnectFailed = this.onReconnectFailed.bind(this)
|
||||||
ble.on('status', this._onStatus)
|
ble.on('status', this._onStatus)
|
||||||
ble.on('treatment_complete', this._onComplete)
|
ble.on('treatment_complete', this._onComplete)
|
||||||
ble.on('exception', this._onException)
|
ble.on('exception', this._onException)
|
||||||
|
ble.on('disconnected', this._onDisconnect)
|
||||||
|
ble.on('reconnected', this._onReconnect)
|
||||||
|
ble.on('reconnect_failed', this._onReconnectFailed)
|
||||||
this.startLocalTimer()
|
this.startLocalTimer()
|
||||||
this.syncCommands()
|
this.syncCommands()
|
||||||
},
|
},
|
||||||
@@ -44,6 +50,9 @@ Page({
|
|||||||
if (this._onStatus) ble.off('status', this._onStatus)
|
if (this._onStatus) ble.off('status', this._onStatus)
|
||||||
if (this._onComplete) ble.off('treatment_complete', this._onComplete)
|
if (this._onComplete) ble.off('treatment_complete', this._onComplete)
|
||||||
if (this._onException) ble.off('exception', this._onException)
|
if (this._onException) ble.off('exception', this._onException)
|
||||||
|
if (this._onDisconnect) ble.off('disconnected', this._onDisconnect)
|
||||||
|
if (this._onReconnect) ble.off('reconnected', this._onReconnect)
|
||||||
|
if (this._onReconnectFailed) ble.off('reconnect_failed', this._onReconnectFailed)
|
||||||
if (this._localTimer) clearInterval(this._localTimer)
|
if (this._localTimer) clearInterval(this._localTimer)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -131,6 +140,40 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onDisconnect: function () {
|
||||||
|
this.setData({ paused: true })
|
||||||
|
wx.showToast({
|
||||||
|
title: '设备连接断开,正在重连...',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onReconnect: function () {
|
||||||
|
this.setData({ paused: false })
|
||||||
|
wx.showToast({
|
||||||
|
title: '设备已重新连接',
|
||||||
|
icon: 'success',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onReconnectFailed: function () {
|
||||||
|
var self = this
|
||||||
|
if (this._localTimer) {
|
||||||
|
clearInterval(this._localTimer)
|
||||||
|
this._localTimer = null
|
||||||
|
}
|
||||||
|
wx.showModal({
|
||||||
|
title: '连接丢失',
|
||||||
|
content: '设备蓝牙连接已断开,无法恢复。本次护理数据将保存。',
|
||||||
|
showCancel: false,
|
||||||
|
complete: function () {
|
||||||
|
self.finishAsComplete()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
onStop: function () {
|
onStop: function () {
|
||||||
var self = this
|
var self = this
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
|
|||||||
+106
-6
@@ -88,6 +88,8 @@ var _chars = {}
|
|||||||
var _cmdSeq = 0
|
var _cmdSeq = 0
|
||||||
var _pendingAcks = {}
|
var _pendingAcks = {}
|
||||||
var _listeners = {}
|
var _listeners = {}
|
||||||
|
var _autoReconnect = true
|
||||||
|
var _reconnecting = false
|
||||||
|
|
||||||
function nextSeq() {
|
function nextSeq() {
|
||||||
_cmdSeq = (_cmdSeq + 1) % 256
|
_cmdSeq = (_cmdSeq + 1) % 256
|
||||||
@@ -456,6 +458,28 @@ function writeCommand(type, payload) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeCommandWithRetry(type, payload, maxRetries) {
|
||||||
|
if (maxRetries === undefined) maxRetries = 3
|
||||||
|
var attempt = 0
|
||||||
|
function tryOnce() {
|
||||||
|
attempt++
|
||||||
|
return writeCommand(type, payload).catch(function (err) {
|
||||||
|
if (err && err.error_code === 0x0C) {
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
if (attempt >= maxRetries) {
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
setTimeout(resolve, 500)
|
||||||
|
}).then(function () {
|
||||||
|
return tryOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tryOnce()
|
||||||
|
}
|
||||||
|
|
||||||
function readDeviceInfo() {
|
function readDeviceInfo() {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
if (!_connected || !_deviceId || !_chars.deviceInfo) {
|
if (!_connected || !_deviceId || !_chars.deviceInfo) {
|
||||||
@@ -507,20 +531,20 @@ function setParams(options) {
|
|||||||
return a.concat(Array.isArray(b) ? b : [b])
|
return a.concat(Array.isArray(b) ? b : [b])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return writeCommand(CMD.SET_PARAMS, payload)
|
return writeCommandWithRetry(CMD.SET_PARAMS, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTreatment(regionMask) {
|
function startTreatment(regionMask) {
|
||||||
var mask = regionMask || REGION.FULL_FACE
|
var mask = regionMask || REGION.FULL_FACE
|
||||||
return writeCommand(CMD.START, [mask])
|
return writeCommandWithRetry(CMD.START, [mask])
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTreatment() {
|
function stopTreatment() {
|
||||||
return writeCommand(CMD.STOP, [])
|
return writeCommandWithRetry(CMD.STOP, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryStatus() {
|
function queryStatus() {
|
||||||
return writeCommand(CMD.QUERY_STATUS, [])
|
return writeCommandWithRetry(CMD.QUERY_STATUS, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindDevice(userId, bindToken) {
|
function bindDevice(userId, bindToken) {
|
||||||
@@ -530,13 +554,13 @@ function bindDevice(userId, bindToken) {
|
|||||||
var tsBytes = uint32ToBytes(ts)
|
var tsBytes = uint32ToBytes(ts)
|
||||||
|
|
||||||
var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
|
var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
|
||||||
return writeCommand(CMD.BIND, payload)
|
return writeCommandWithRetry(CMD.BIND, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function unbindDevice(userId) {
|
function unbindDevice(userId) {
|
||||||
var userBytes = hexToBytes(userId)
|
var userBytes = hexToBytes(userId)
|
||||||
var payload = [0x02].concat(userBytes)
|
var payload = [0x02].concat(userBytes)
|
||||||
return writeCommand(CMD.UNBIND, payload)
|
return writeCommandWithRetry(CMD.UNBIND, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopScan() {
|
function stopScan() {
|
||||||
@@ -545,6 +569,8 @@ function stopScan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
_autoReconnect = false
|
||||||
|
_reconnecting = false
|
||||||
if (_deviceId) {
|
if (_deviceId) {
|
||||||
wx.closeBLEConnection({ deviceId: _deviceId })
|
wx.closeBLEConnection({ deviceId: _deviceId })
|
||||||
_deviceId = null
|
_deviceId = null
|
||||||
@@ -556,10 +582,83 @@ function disconnect() {
|
|||||||
wx.closeBluetoothAdapter({})
|
wx.closeBluetoothAdapter({})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attemptReconnect(deviceId, retriesLeft) {
|
||||||
|
wx.createBLEConnection({
|
||||||
|
deviceId: deviceId,
|
||||||
|
timeout: 10000,
|
||||||
|
success: function () {
|
||||||
|
_connected = true
|
||||||
|
_chars = {}
|
||||||
|
wx.getBLEDeviceServices({
|
||||||
|
deviceId: deviceId,
|
||||||
|
success: function (res) {
|
||||||
|
var services = res.services
|
||||||
|
var serviceMap = {}
|
||||||
|
for (var i = 0; i < services.length; i++) {
|
||||||
|
var uuid = services[i].uuid.toUpperCase()
|
||||||
|
if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) {
|
||||||
|
serviceMap.deviceInfo = services[i].uuid
|
||||||
|
} else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) {
|
||||||
|
serviceMap.dataComm = services[i].uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = []
|
||||||
|
if (serviceMap.deviceInfo) {
|
||||||
|
tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo'))
|
||||||
|
}
|
||||||
|
if (serviceMap.dataComm) {
|
||||||
|
tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm'))
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(tasks).then(function () {
|
||||||
|
var serviceId = serviceMap.dataComm || null
|
||||||
|
return subscribeToNotifications(deviceId, serviceId)
|
||||||
|
}).then(function () {
|
||||||
|
_reconnecting = false
|
||||||
|
emit('reconnected', { deviceId: deviceId })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: function () {
|
||||||
|
// Service discovery failed, treat as reconnect failure
|
||||||
|
if (retriesLeft > 1) {
|
||||||
|
setTimeout(function () {
|
||||||
|
attemptReconnect(deviceId, retriesLeft - 1)
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
_reconnecting = false
|
||||||
|
emit('reconnect_failed', { deviceId: deviceId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: function () {
|
||||||
|
if (retriesLeft > 1) {
|
||||||
|
setTimeout(function () {
|
||||||
|
attemptReconnect(deviceId, retriesLeft - 1)
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
_reconnecting = false
|
||||||
|
emit('reconnect_failed', { deviceId: deviceId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoReconnect(enabled) {
|
||||||
|
_autoReconnect = enabled
|
||||||
|
}
|
||||||
|
|
||||||
wx.onBLEConnectionStateChange(function (res) {
|
wx.onBLEConnectionStateChange(function (res) {
|
||||||
if (!res.connected) {
|
if (!res.connected) {
|
||||||
_connected = false
|
_connected = false
|
||||||
emit('disconnected', { deviceId: res.deviceId })
|
emit('disconnected', { deviceId: res.deviceId })
|
||||||
|
if (_autoReconnect && !_reconnecting && _deviceId) {
|
||||||
|
_reconnecting = true
|
||||||
|
setTimeout(function () {
|
||||||
|
attemptReconnect(_deviceId, 3)
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -605,6 +704,7 @@ module.exports = {
|
|||||||
stopScan: stopScan,
|
stopScan: stopScan,
|
||||||
connect: connect,
|
connect: connect,
|
||||||
disconnect: disconnect,
|
disconnect: disconnect,
|
||||||
|
setAutoReconnect: setAutoReconnect,
|
||||||
on: on,
|
on: on,
|
||||||
off: off,
|
off: off,
|
||||||
|
|
||||||
|
|||||||
+77
-20
@@ -1,38 +1,95 @@
|
|||||||
var config = require('../config/env')
|
var config = require('../config/env')
|
||||||
var API_BASE = config.API_BASE
|
var API_BASE = config.API_BASE
|
||||||
|
var _refreshing = false
|
||||||
|
var _refreshQueue = []
|
||||||
|
|
||||||
function httpRequest(options) {
|
function getTokenExpiry() {
|
||||||
var token = wx.getStorageSync('token')
|
return parseInt(wx.getStorageSync('token_expiry') || '0', 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRefresh() {
|
||||||
|
var expiry = getTokenExpiry()
|
||||||
|
if (!expiry) return false
|
||||||
|
var remaining = expiry - Math.floor(Date.now() / 1000)
|
||||||
|
return remaining > 0 && remaining < 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshToken() {
|
||||||
|
if (_refreshing) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
_refreshQueue.push({ resolve: resolve, reject: reject })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_refreshing = true
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
var token = wx.getStorageSync('token')
|
||||||
wx.request({
|
wx.request({
|
||||||
url: API_BASE + options.path,
|
url: API_BASE + '/api/v1/auth/refresh',
|
||||||
method: options.method || 'GET',
|
method: 'POST',
|
||||||
data: options.data || {},
|
header: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
header: Object.assign({
|
data: {},
|
||||||
'Authorization': token ? 'Bearer ' + token : '',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-App-Version': '1.0.0',
|
|
||||||
'X-Platform': 'wechat'
|
|
||||||
}, options.header || {}),
|
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
if (res.data && res.data.code === 0) {
|
if (res.data && res.data.code === 0 && res.data.data && res.data.data.token) {
|
||||||
resolve(res.data.data)
|
wx.setStorageSync('token', res.data.data.token)
|
||||||
} else if (res.data && res.data.code === 1001 || res.data && res.data.code === 1002) {
|
wx.setStorageSync('token_expiry', String(Math.floor(Date.now() / 1000) + (res.data.data.expires_in || 604800)))
|
||||||
wx.removeStorageSync('token')
|
resolve(res.data.data.token)
|
||||||
wx.reLaunch({ url: '/pages/login/login' })
|
_refreshQueue.forEach(function (q) { q.resolve(res.data.data.token) })
|
||||||
reject(res.data)
|
|
||||||
} else {
|
} else {
|
||||||
reject(res.data || { code: -1, message: '请求失败' })
|
resolve(null)
|
||||||
|
_refreshQueue.forEach(function (q) { q.resolve(null) })
|
||||||
}
|
}
|
||||||
|
_refreshing = false
|
||||||
|
_refreshQueue = []
|
||||||
},
|
},
|
||||||
fail: function (err) {
|
fail: function () {
|
||||||
reject({ code: 2002, message: err.errMsg || '网络异常' })
|
resolve(null)
|
||||||
|
_refreshQueue.forEach(function (q) { q.resolve(null) })
|
||||||
|
_refreshing = false
|
||||||
|
_refreshQueue = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function httpRequest(options) {
|
||||||
|
var doRequest = function () {
|
||||||
|
var token = wx.getStorageSync('token')
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
wx.request({
|
||||||
|
url: API_BASE + options.path,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data || {},
|
||||||
|
header: Object.assign({
|
||||||
|
'Authorization': token ? 'Bearer ' + token : '',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-App-Version': '1.0.0',
|
||||||
|
'X-Platform': 'wechat'
|
||||||
|
}, options.header || {}),
|
||||||
|
success: function (res) {
|
||||||
|
if (res.data && res.data.code === 0) {
|
||||||
|
resolve(res.data.data)
|
||||||
|
} else if (res.data && res.data.code === 1001 || res.data && res.data.code === 1002) {
|
||||||
|
wx.removeStorageSync('token')
|
||||||
|
wx.removeStorageSync('token_expiry')
|
||||||
|
wx.reLaunch({ url: '/pages/login/login' })
|
||||||
|
reject(res.data)
|
||||||
|
} else {
|
||||||
|
reject(res.data || { code: -1, message: '请求失败' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: function (err) {
|
||||||
|
reject({ code: 2002, message: err.errMsg || '网络异常' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRefresh() && options.path !== '/api/v1/auth/refresh') {
|
||||||
|
return refreshToken().then(function () { return doRequest() })
|
||||||
|
}
|
||||||
|
return doRequest()
|
||||||
|
}
|
||||||
|
|
||||||
function request(options) {
|
function request(options) {
|
||||||
return httpRequest(options)
|
return httpRequest(options)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "hox-scf-api",
|
"name": "hox-scf-api",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cos-nodejs-sdk-v5": "^2.14.7",
|
"cos-nodejs-sdk-v5": "^2.14.7",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -124,6 +125,12 @@
|
|||||||
"tweetnacl": "^0.14.3"
|
"tweetnacl": "^0.14.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"db:init": "node scripts/init-db.js"
|
"db:init": "node scripts/init-db.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"cos-nodejs-sdk-v5": "^2.14.7",
|
"cos-nodejs-sdk-v5": "^2.14.7",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|||||||
+11
-2
@@ -1,12 +1,21 @@
|
|||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const jwt = require('jsonwebtoken')
|
const jwt = require('jsonwebtoken')
|
||||||
|
const bcrypt = require('bcryptjs')
|
||||||
const config = require('../config')
|
const config = require('../config')
|
||||||
const { one } = require('./db')
|
const { one } = require('./db')
|
||||||
|
|
||||||
function hashPassword(password, salt) {
|
function hashPasswordLegacy(password, salt) {
|
||||||
return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex')
|
return crypto.createHash('sha256').update(String(password) + ':' + salt).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashPassword(password) {
|
||||||
|
return bcrypt.hashSync(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, hash) {
|
||||||
|
return bcrypt.compareSync(password, hash)
|
||||||
|
}
|
||||||
|
|
||||||
function randomHex(bytes) {
|
function randomHex(bytes) {
|
||||||
return crypto.randomBytes(bytes).toString('hex')
|
return crypto.randomBytes(bytes).toString('hex')
|
||||||
}
|
}
|
||||||
@@ -49,4 +58,4 @@ async function requireAdmin(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { hashPassword, randomHex, signUser, signAdmin, requireUser, requireAdmin }
|
module.exports = { hashPassword, hashPasswordLegacy, verifyPassword, randomHex, signUser, signAdmin, readBearer, requireUser, requireAdmin }
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,6 @@
|
|||||||
const { one, query, limitClause } = require('../lib/db')
|
const { one, query, limitClause } = require('../lib/db')
|
||||||
const { ok, fail } = require('../lib/response')
|
const { ok, fail } = require('../lib/response')
|
||||||
const { hashPassword, signAdmin, requireAdmin } = require('../lib/auth')
|
const { hashPassword, hashPasswordLegacy, verifyPassword, signAdmin, requireAdmin } = require('../lib/auth')
|
||||||
const { writeLog } = require('../lib/log')
|
const { writeLog } = require('../lib/log')
|
||||||
|
|
||||||
function pageParams(ctx) {
|
function pageParams(ctx) {
|
||||||
@@ -14,7 +14,18 @@ function register(router) {
|
|||||||
const username = ctx.body.username || ''
|
const username = ctx.body.username || ''
|
||||||
const password = ctx.body.password || ''
|
const password = ctx.body.password || ''
|
||||||
const admin = await one('SELECT * FROM admin_accounts WHERE username = :username AND status = 1', { username })
|
const admin = await one('SELECT * FROM admin_accounts WHERE username = :username AND status = 1', { username })
|
||||||
if (!admin || hashPassword(password, admin.password_salt) !== admin.password_hash) return fail(1001, '用户名或密码错误')
|
if (!admin) return fail(1001, '用户名或密码错误')
|
||||||
|
let matched = verifyPassword(password, admin.password_hash)
|
||||||
|
if (!matched) {
|
||||||
|
// Try legacy SHA-256 verification for migration
|
||||||
|
if (admin.password_salt && hashPasswordLegacy(password, admin.password_salt) === admin.password_hash) {
|
||||||
|
// Auto-migrate to bcrypt
|
||||||
|
const newHash = await hashPassword(password)
|
||||||
|
await query('UPDATE admin_accounts SET password_hash = :password_hash, password_salt = NULL WHERE admin_id = :admin_id', { password_hash: newHash, admin_id: admin.admin_id })
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) return fail(1001, '用户名或密码错误')
|
||||||
const token = signAdmin(admin)
|
const token = signAdmin(admin)
|
||||||
await writeLog({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: ctx.ip })
|
await writeLog({ admin_id: admin.admin_id, action: 'admin_login', detail: '管理员登录: ' + username, ip: ctx.ip })
|
||||||
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
|
return ok({ token, admin_id: String(admin.admin_id), username: admin.username, real_name: admin.real_name, role: admin.role })
|
||||||
|
|||||||
+40
-2
@@ -1,8 +1,10 @@
|
|||||||
|
const jwt = require('jsonwebtoken')
|
||||||
const { one, query } = require('../lib/db')
|
const { one, query } = require('../lib/db')
|
||||||
const { ok, fail } = require('../lib/response')
|
const { ok, fail } = require('../lib/response')
|
||||||
const { signUser } = require('../lib/auth')
|
const { signUser, readBearer } = require('../lib/auth')
|
||||||
const { code2Session } = require('../lib/wechat')
|
const { code2Session } = require('../lib/wechat')
|
||||||
const { writeLog } = require('../lib/log')
|
const { writeLog } = require('../lib/log')
|
||||||
|
const config = require('../config')
|
||||||
|
|
||||||
function register(router) {
|
function register(router) {
|
||||||
router.post('/api/v1/auth/login', async ctx => {
|
router.post('/api/v1/auth/login', async ctx => {
|
||||||
@@ -33,7 +35,43 @@ function register(router) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/api/v1/auth/refresh', async ctx => {
|
router.post('/api/v1/auth/refresh', async ctx => {
|
||||||
return fail(2001, 'refresh_token 暂未启用,请重新登录')
|
const token = readBearer(ctx.headers)
|
||||||
|
if (!token) return fail(1001, 'token_expired')
|
||||||
|
|
||||||
|
let payload
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, config.jwt.secret)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, config.jwt.secret, { ignoreExpiration: true })
|
||||||
|
} catch (_) {
|
||||||
|
return fail(1001, 'token_expired')
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const gracePeriod = 3 * 24 * 60 * 60
|
||||||
|
if (now - payload.exp > gracePeriod) {
|
||||||
|
return fail(1001, 'token_expired')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fail(1001, 'token_expired')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type !== 'user') return fail(1001, 'token_expired')
|
||||||
|
|
||||||
|
// Check if token is within 7 days of expiry (for non-expired tokens)
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const sevenDays = 7 * 24 * 60 * 60
|
||||||
|
if (payload.exp && payload.exp > now && (payload.exp - now) > sevenDays) {
|
||||||
|
return ok({ token, expires_in: payload.exp - now })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await one('SELECT * FROM users WHERE user_id = :user_id AND status = 1', { user_id: payload.user_id })
|
||||||
|
if (!user) return fail(1001, 'token_expired')
|
||||||
|
|
||||||
|
const newToken = signUser(user)
|
||||||
|
return ok({ token: newToken, expires_in: 604800 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ function register(router) {
|
|||||||
await writeLog({ user_id: user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: ctx.ip })
|
await writeLog({ user_id: user.user_id, action: 'treatment_sync', detail: '同步护理记录: ' + sessionId, ip: ctx.ip })
|
||||||
return ok({ record_id: sessionId })
|
return ok({ record_id: sessionId })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/api/v1/treatment/:record_id', async ctx => {
|
||||||
|
const user = await requireUser(ctx)
|
||||||
|
if (!user) return fail(1001, 'invalid_token')
|
||||||
|
const record = await one('SELECT * FROM treatment_records WHERE session_id = :session_id AND user_id = :user_id', { session_id: ctx.params.record_id, user_id: user.user_id })
|
||||||
|
if (!record) return fail(1005, 'record_not_found')
|
||||||
|
return ok(record)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = register
|
module.exports = register
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户