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) {
http.post('/api/v1/auth/login', { code: res.code }).then(function (data) {
wx.setStorageSync('token', data.token)
wx.setStorageSync('token_expiry', String(Math.floor(Date.now() / 1000) + (data.expires_in || 604800)))
var app = getApp()
app.globalData.userInfo = data.user_info
app.globalData.userId = data.user_id
+43
查看文件
@@ -33,9 +33,15 @@ Page({
this._onStatus = this.onStatus.bind(this)
this._onComplete = this.onComplete.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('treatment_complete', this._onComplete)
ble.on('exception', this._onException)
ble.on('disconnected', this._onDisconnect)
ble.on('reconnected', this._onReconnect)
ble.on('reconnect_failed', this._onReconnectFailed)
this.startLocalTimer()
this.syncCommands()
},
@@ -44,6 +50,9 @@ Page({
if (this._onStatus) ble.off('status', this._onStatus)
if (this._onComplete) ble.off('treatment_complete', this._onComplete)
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)
},
@@ -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 () {
var self = this
wx.showModal({
+106 -6
查看文件
@@ -88,6 +88,8 @@ var _chars = {}
var _cmdSeq = 0
var _pendingAcks = {}
var _listeners = {}
var _autoReconnect = true
var _reconnecting = false
function nextSeq() {
_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() {
return new Promise(function (resolve, reject) {
if (!_connected || !_deviceId || !_chars.deviceInfo) {
@@ -507,20 +531,20 @@ function setParams(options) {
return a.concat(Array.isArray(b) ? b : [b])
}, [])
return writeCommand(CMD.SET_PARAMS, payload)
return writeCommandWithRetry(CMD.SET_PARAMS, payload)
}
function startTreatment(regionMask) {
var mask = regionMask || REGION.FULL_FACE
return writeCommand(CMD.START, [mask])
return writeCommandWithRetry(CMD.START, [mask])
}
function stopTreatment() {
return writeCommand(CMD.STOP, [])
return writeCommandWithRetry(CMD.STOP, [])
}
function queryStatus() {
return writeCommand(CMD.QUERY_STATUS, [])
return writeCommandWithRetry(CMD.QUERY_STATUS, [])
}
function bindDevice(userId, bindToken) {
@@ -530,13 +554,13 @@ function bindDevice(userId, bindToken) {
var tsBytes = uint32ToBytes(ts)
var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes)
return writeCommand(CMD.BIND, payload)
return writeCommandWithRetry(CMD.BIND, payload)
}
function unbindDevice(userId) {
var userBytes = hexToBytes(userId)
var payload = [0x02].concat(userBytes)
return writeCommand(CMD.UNBIND, payload)
return writeCommandWithRetry(CMD.UNBIND, payload)
}
function stopScan() {
@@ -545,6 +569,8 @@ function stopScan() {
}
function disconnect() {
_autoReconnect = false
_reconnecting = false
if (_deviceId) {
wx.closeBLEConnection({ deviceId: _deviceId })
_deviceId = null
@@ -556,10 +582,83 @@ function disconnect() {
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) {
if (!res.connected) {
_connected = false
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,
connect: connect,
disconnect: disconnect,
setAutoReconnect: setAutoReconnect,
on: on,
off: off,
+58 -1
查看文件
@@ -1,9 +1,59 @@
var config = require('../config/env')
var API_BASE = config.API_BASE
var _refreshing = false
var _refreshQueue = []
function getTokenExpiry() {
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) {
var token = wx.getStorageSync('token')
wx.request({
url: API_BASE + '/api/v1/auth/refresh',
method: 'POST',
header: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
data: {},
success: function (res) {
if (res.data && res.data.code === 0 && res.data.data && res.data.data.token) {
wx.setStorageSync('token', res.data.data.token)
wx.setStorageSync('token_expiry', String(Math.floor(Date.now() / 1000) + (res.data.data.expires_in || 604800)))
resolve(res.data.data.token)
_refreshQueue.forEach(function (q) { q.resolve(res.data.data.token) })
} else {
resolve(null)
_refreshQueue.forEach(function (q) { q.resolve(null) })
}
_refreshing = false
_refreshQueue = []
},
fail: function () {
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,
@@ -20,6 +70,7 @@ function httpRequest(options) {
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 {
@@ -31,6 +82,12 @@ function httpRequest(options) {
}
})
})
}
if (shouldRefresh() && options.path !== '/api/v1/auth/refresh') {
return refreshToken().then(function () { return doRequest() })
}
return doRequest()
}
function request(options) {
+7
查看文件
@@ -8,6 +8,7 @@
"name": "hox-scf-api",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.14.7",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
@@ -124,6 +125,12 @@
"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": {
"version": "1.0.1",
"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"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cos-nodejs-sdk-v5": "^2.14.7",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
+11 -2
查看文件
@@ -1,12 +1,21 @@
const crypto = require('crypto')
const jwt = require('jsonwebtoken')
const bcrypt = require('bcryptjs')
const config = require('../config')
const { one } = require('./db')
function hashPassword(password, salt) {
function hashPasswordLegacy(password, salt) {
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) {
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 { 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')
function pageParams(ctx) {
@@ -14,7 +14,18 @@ function register(router) {
const username = ctx.body.username || ''
const password = ctx.body.password || ''
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)
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 })
+40 -2
查看文件
@@ -1,8 +1,10 @@
const jwt = require('jsonwebtoken')
const { one, query } = require('../lib/db')
const { ok, fail } = require('../lib/response')
const { signUser } = require('../lib/auth')
const { signUser, readBearer } = require('../lib/auth')
const { code2Session } = require('../lib/wechat')
const { writeLog } = require('../lib/log')
const config = require('../config')
function register(router) {
router.post('/api/v1/auth/login', async ctx => {
@@ -33,7 +35,43 @@ function register(router) {
})
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 })
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