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
这个提交包含在:
Guoguo
2026-04-28 18:12:30 -07:00
父节点 88adee7743
当前提交 91d5937d8e
修改 10 个文件,包含 307 行新增32 行删除
+1
查看文件
@@ -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
+43
查看文件
@@ -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",
+1
查看文件
@@ -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 })
}) })
} }
+8
查看文件
@@ -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