From f7cd9575d2c4aa25680e9dbf139d48917ea2e0fc Mon Sep 17 00:00:00 2001 From: Alfred Date: Mon, 25 May 2026 23:20:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20root=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20admin=20=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ts_api/src/ts_api.c | 4 +- components/ts_api/src/ts_api_auth.c | 118 +++++++++++++++- components/ts_security/include/ts_security.h | 7 + components/ts_security/src/ts_auth.c | 92 ++++++------ components/ts_webui/web/css/style.css | 131 +++++++++++++++++ components/ts_webui/web/js/api.js | 56 +++++--- components/ts_webui/web/js/app.js | 141 ++++++++++++++++++- components/ts_webui/web/js/lang/en-US.js | 19 +++ components/ts_webui/web/js/lang/zh-CN.js | 19 +++ docs/API_REFERENCE.md | 43 +++++- docs/QUICK_START.md | 4 +- version.txt | 2 +- 12 files changed, 565 insertions(+), 71 deletions(-) diff --git a/components/ts_api/src/ts_api.c b/components/ts_api/src/ts_api.c index c4f2404..0ff1f1a 100644 --- a/components/ts_api/src/ts_api.c +++ b/components/ts_api/src/ts_api.c @@ -31,7 +31,9 @@ static const char *s_code_names[] = { [TS_API_ERR_NO_MEM] = "NO_MEM", [TS_API_ERR_INTERNAL] = "INTERNAL", [TS_API_ERR_NOT_SUPPORTED] = "NOT_SUPPORTED", - [TS_API_ERR_HARDWARE] = "HARDWARE" + [TS_API_ERR_HARDWARE] = "HARDWARE", + [TS_API_ERR_CONNECTION] = "CONNECTION", + [TS_API_ERR_AUTH] = "AUTH" }; static const char *s_category_names[] = { diff --git a/components/ts_api/src/ts_api_auth.c b/components/ts_api/src/ts_api_auth.c index a411b36..a021362 100644 --- a/components/ts_api/src/ts_api_auth.c +++ b/components/ts_api/src/ts_api_auth.c @@ -1,7 +1,7 @@ /** * @file ts_ap * @brief Authentication API and - * 提供 auth.login / auth.lout / auth.status / auth.change_password API + * 提供 auth.login / auth.logout / auth.status / auth.change_password API * * @author TianShanOS Team * @version 1.0.0 @@ -32,6 +32,35 @@ static const char *perm_level_to_string(ts_perm_level_t level) } } +static esp_err_t validate_root_token(const cJSON *token_item, ts_api_result_t *result) +{ + if (!cJSON_IsString(token_item)) { + ts_api_result_error(result, TS_API_ERR_AUTH, "Missing token"); + return ESP_ERR_INVALID_ARG; + } + + uint32_t session_id; + esp_err_t ret = ts_security_validate_token(token_item->valuestring, &session_id); + if (ret != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_AUTH, "Invalid or expired token"); + return ret; + } + + ts_session_t session; + ret = ts_security_validate_session(session_id, &session); + if (ret != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_AUTH, "Session expired"); + return ret; + } + + if (session.level != TS_PERM_ROOT) { + ts_api_result_error(result, TS_API_ERR_NO_PERMISSION, "Root permission required"); + return ESP_ERR_NOT_ALLOWED; + } + + return ESP_OK; +} + /*===========================================================================*/ /* API Handlers */ /*===========================================================================*/ @@ -255,6 +284,79 @@ static esp_err_t api_auth_change_password(const cJSON *params, ts_api_result_t * return ESP_OK; } +/** + * @brief auth.admin.set_password - Root sets admin password + * + * Params: { "token": "...", "new_password": "..." } + * Returns: { "success": true, "username": "admin", "password_changed": true } + */ +static esp_err_t api_auth_admin_set_password(const cJSON *params, ts_api_result_t *result) +{ + const cJSON *token_item = cJSON_GetObjectItem(params, "token"); + const cJSON *new_pwd_item = cJSON_GetObjectItem(params, "new_password"); + + esp_err_t ret = validate_root_token(token_item, result); + if (ret != ESP_OK) return ret; + + if (!cJSON_IsString(new_pwd_item)) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, + "Missing required parameter: new_password"); + return ESP_ERR_INVALID_ARG; + } + + const char *new_password = new_pwd_item->valuestring; + size_t len = strlen(new_password); + if (len < 4 || len > 64) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, + "Password must be 4-64 characters"); + return ESP_ERR_INVALID_ARG; + } + + ret = ts_auth_set_admin_password(new_password); + if (ret != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to set admin password"); + return ret; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "success", true); + cJSON_AddStringToObject(data, "username", "admin"); + cJSON_AddBoolToObject(data, "password_changed", true); + + ts_api_result_ok(result, data); + TS_LOGI(TAG, "Root set admin password"); + return ESP_OK; +} + +/** + * @brief auth.admin.reset_password - Root resets admin password to default + * + * Params: { "token": "..." } + * Returns: { "success": true, "username": "admin", "password_changed": false } + */ +static esp_err_t api_auth_admin_reset_password(const cJSON *params, ts_api_result_t *result) +{ + const cJSON *token_item = cJSON_GetObjectItem(params, "token"); + + esp_err_t ret = validate_root_token(token_item, result); + if (ret != ESP_OK) return ret; + + ret = ts_auth_reset_password("admin"); + if (ret != ESP_OK) { + ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to reset admin password"); + return ret; + } + + cJSON *data = cJSON_CreateObject(); + cJSON_AddBoolToObject(data, "success", true); + cJSON_AddStringToObject(data, "username", "admin"); + cJSON_AddBoolToObject(data, "password_changed", false); + + ts_api_result_ok(result, data); + TS_LOGI(TAG, "Root reset admin password to default"); + return ESP_OK; +} + /*===========================================================================*/ /* Registration */ /*===========================================================================*/ @@ -288,6 +390,20 @@ static const ts_api_endpoint_t s_auth_endpoints[] = { .handler = api_auth_change_password, .requires_auth = false, /* 密码修改使用 token 验证 */ }, + { + .name = "auth.admin.set_password", + .description = "Root sets admin password", + .category = TS_API_CAT_SECURITY, + .handler = api_auth_admin_set_password, + .requires_auth = false, /* Handler validates root token */ + }, + { + .name = "auth.admin.reset_password", + .description = "Root resets admin password to default", + .category = TS_API_CAT_SECURITY, + .handler = api_auth_admin_reset_password, + .requires_auth = false, /* Handler validates root token */ + }, }; esp_err_t ts_api_auth_register(void) diff --git a/components/ts_security/include/ts_security.h b/components/ts_security/include/ts_security.h index 31927a2..1d32f68 100644 --- a/components/ts_security/include/ts_security.h +++ b/components/ts_security/include/ts_security.h @@ -156,6 +156,13 @@ esp_err_t ts_auth_verify_password(const char *username, const char *password, esp_err_t ts_auth_change_password(const char *username, const char *old_password, const char *new_password); +/** + * @brief Set admin password without requiring the old admin password + * @param new_password New admin password (4-64 chars) + * @return ESP_OK on success + */ +esp_err_t ts_auth_set_admin_password(const char *new_password); + /** * @brief Check if user has changed the default password */ diff --git a/components/ts_security/src/ts_auth.c b/components/ts_security/src/ts_auth.c index 7eaa014..e0cd758 100644 --- a/components/ts_security/src/ts_auth.c +++ b/components/ts_security/src/ts_auth.c @@ -6,7 +6,7 @@ * * 安全设计: * - 密码哈希仅存储在 NVS,不导出到 SD 卡 - * - 忘记密码只能通过 idf.py erase-flash 恢复出厂 + * - admin 密码可由 root 会话恢复,root 密码遗失需恢复出厂 */ #include "ts_security.h" @@ -99,30 +99,45 @@ static esp_err_t save_user_credential(const char *username, const user_credentia } /** - * @brief 强制重新创建用户凭据 + * @brief 写入用户密码凭据,并清除失败计数/锁定状态 */ -static esp_err_t force_create_user(const char *username, ts_perm_level_t level) +static esp_err_t write_user_password_credential(const char *username, const char *password, + bool password_changed) { - user_credential_t cred; - - /* 根据用户类型使用不同默认密码 */ - const char *default_pwd = (level == TS_PERM_ROOT) ? DEFAULT_PASSWORD_ROOT : DEFAULT_PASSWORD_ADMIN; - TS_LOGI(TAG, "Creating/resetting user '%s'", username); - - /* 生成随机 salt */ + if (!username || !password) return ESP_ERR_INVALID_ARG; + + size_t pwd_len = strlen(password); + if (pwd_len < 4 || pwd_len > 64) { + TS_LOGW(TAG, "Password length invalid (4-64 chars required)"); + return ESP_ERR_INVALID_ARG; + } + + user_credential_t cred = {0}; + esp_fill_random(cred.salt, SALT_LEN); - - /* 计算默认密码哈希 */ - esp_err_t ret = compute_password_hash(cred.salt, default_pwd, cred.hash); + + esp_err_t ret = compute_password_hash(cred.salt, password, cred.hash); if (ret != ESP_OK) return ret; - - cred.password_changed = false; + + cred.password_changed = password_changed; cred.failed_attempts = 0; cred.lockout_until = 0; - + return save_user_credential(username, &cred); } +/** + * @brief 强制重新创建用户凭据 + */ +static esp_err_t force_create_user(const char *username, ts_perm_level_t level) +{ + /* 根据用户类型使用不同默认密码 */ + const char *default_pwd = (level == TS_PERM_ROOT) ? DEFAULT_PASSWORD_ROOT : DEFAULT_PASSWORD_ADMIN; + TS_LOGI(TAG, "Creating/resetting user '%s'", username); + + return write_user_password_credential(username, default_pwd, false); +} + /** * @brief 初始化用户(如果不存在则创建默认密码) */ @@ -279,22 +294,7 @@ esp_err_t ts_auth_change_password(const char *username, const char *old_password return ret; } - /* 加载凭据 */ - user_credential_t cred; - ret = load_user_credential(username, &cred); - if (ret != ESP_OK) return ret; - - /* 生成新 salt */ - esp_fill_random(cred.salt, SALT_LEN); - - /* 计算新密码哈希 */ - ret = compute_password_hash(cred.salt, new_password, cred.hash); - if (ret != ESP_OK) return ret; - - cred.password_changed = true; - cred.failed_attempts = 0; - - ret = save_user_credential(username, &cred); + ret = write_user_password_credential(username, new_password, true); if (ret == ESP_OK) { TS_LOGI(TAG, "Password changed for user %s", username); } @@ -302,6 +302,19 @@ esp_err_t ts_auth_change_password(const char *username, const char *old_password return ret; } +/** + * @brief Root 管理功能:设置 admin 密码 + */ +esp_err_t ts_auth_set_admin_password(const char *new_password) +{ + esp_err_t ret = write_user_password_credential("admin", new_password, true); + if (ret == ESP_OK) { + TS_LOGI(TAG, "Password set for admin by root"); + } + + return ret; +} + /** * @brief 检查用户是否已修改初始密码 */ @@ -399,20 +412,7 @@ esp_err_t ts_auth_reset_password(const char *username) return ESP_ERR_NOT_FOUND; } - user_credential_t cred; - - /* 生成新 salt */ - esp_fill_random(cred.salt, SALT_LEN); - - /* 使用默认密码 */ - esp_err_t ret = compute_password_hash(cred.salt, default_pwd, cred.hash); - if (ret != ESP_OK) return ret; - - cred.password_changed = false; - cred.failed_attempts = 0; - cred.lockout_until = 0; - - ret = save_user_credential(username, &cred); + esp_err_t ret = write_user_password_credential(username, default_pwd, false); if (ret == ESP_OK) { TS_LOGI(TAG, "Password reset to default for user %s", username); } diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index 863c703..b4c2801 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -3091,6 +3091,40 @@ button.btn-gray:hover, max-width: 460px; } +.fan-auto-help-header { + position: relative; + display: flex; + align-items: center; + padding-right: 32px; +} + +.fan-auto-help-header h2 { + height: 28px; + min-width: 0; + line-height: 28px; +} + +.fan-auto-help-header .modal-close { + position: absolute; + top: 2px; + right: 0; + width: 24px; + height: 24px; + min-width: 24px; + margin-left: 0; + font-size: 1rem; + border-radius: var(--radius-sm); +} + +.fan-auto-help-header .modal-close i { + line-height: 1; +} + +.fan-auto-help-header .modal-close:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + .fan-auto-help-body { color: var(--text-secondary); font-size: 0.92rem; @@ -3689,6 +3723,103 @@ button.btn-gray:hover, font-size: 1.15rem; font-weight: 600; } + +.account-security-desc { + color: var(--text-muted); + margin: 0 0 15px; +} + +.account-password-form { + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(240px, 320px) minmax(16px, 1fr) auto; + gap: 12px; + align-items: end; + margin-bottom: 12px; +} + +.account-password-group, +.account-security-actions { + margin-bottom: 0; +} + +.account-security-actions { + grid-column: 4; + justify-self: end; +} + +.account-password-field { + position: relative; +} + +.form-group .account-password-input { + min-height: 38px; + padding-right: 38px; + line-height: 20px; + box-sizing: border-box; +} + +.password-visibility-toggle { + position: absolute; + top: 50%; + right: 6px; + width: 28px; + height: 28px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.password-visibility-toggle:hover { + background: var(--bg-tertiary); + color: var(--blue-600); +} + +.password-visibility-toggle.active { + background: var(--blue-50); + color: var(--blue-600); +} + +.account-danger-row { + border-top: 1px solid var(--border); + padding-top: 12px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: end; +} + +.account-danger-row .btn { + justify-self: end; + align-self: end; +} + +@media (max-width: 780px) { + .account-password-form { + grid-template-columns: 1fr; + } + + .account-security-actions { + grid-column: 1; + justify-self: stretch; + } + + .account-danger-row { + grid-template-columns: 1fr; + } + + .account-security-actions .btn, + .account-danger-row .btn { + width: 100%; + } +} + .page-security .modal .modal-content h2 { font-size: 1.1rem; } diff --git a/components/ts_webui/web/js/api.js b/components/ts_webui/web/js/api.js index 06b39b8..9dddac6 100644 --- a/components/ts_webui/web/js/api.js +++ b/components/ts_webui/web/js/api.js @@ -49,15 +49,20 @@ class TianShanAPI { if (expires && Date.now() > parseInt(expires)) { // Token 已过期,清除 console.log('Token expired, clearing...'); - this.token = null; - this.username = null; - this.level = null; - localStorage.removeItem('ts_token'); - localStorage.removeItem('ts_username'); - localStorage.removeItem('ts_level'); - localStorage.removeItem('ts_expires'); + this.clearAuth(); } } + + clearAuth() { + this.token = null; + this.username = null; + this.level = null; + this.passwordChanged = null; + localStorage.removeItem('ts_token'); + localStorage.removeItem('ts_username'); + localStorage.removeItem('ts_level'); + localStorage.removeItem('ts_expires'); + } /** * 通用 API 请求方法 @@ -118,6 +123,16 @@ class TianShanAPI { if (!response.ok && !json.code) { throw new Error(json.message || json.error || 'Request failed'); } + + const isAuthEndpoint = endpoint === '/auth/login' || endpoint === '/auth/logout'; + if (!isAuthEndpoint && json && json.code === 'AUTH') { + this.clearAuth(); + const message = typeof window.t === 'function' + ? window.t('login.sessionExpired') + : '登录已过期,请重新登录'; + json.message = message; + window.dispatchEvent(new CustomEvent('authExpired', { detail: { message } })); + } return json; } catch (error) { @@ -171,7 +186,7 @@ class TianShanAPI { async login(username, password) { const result = await this.call('auth.login', { username, password }, 'POST'); - if (result.code === 0 && result.data?.token) { + if ((result.success || result.code === 0 || result.code === 'OK') && result.data?.token) { this.token = result.data.token; this.username = result.data.username; this.level = result.data.level; @@ -190,20 +205,14 @@ class TianShanAPI { await this.call('auth.logout', { token: this.token }, 'POST'); } } finally { - this.token = null; - this.username = null; - this.level = null; - localStorage.removeItem('ts_token'); - localStorage.removeItem('ts_username'); - localStorage.removeItem('ts_level'); - localStorage.removeItem('ts_expires'); + this.clearAuth(); } } async checkAuthStatus() { if (!this.token) return { valid: false }; const result = await this.call('auth.status', { token: this.token }, 'POST'); - if (result.code === 0 && result.data) { + if ((result.success || result.code === 0 || result.code === 'OK') && result.data) { return result.data; } return { valid: false }; @@ -216,6 +225,19 @@ class TianShanAPI { new_password: newPassword }, 'POST'); } + + async setAdminPassword(newPassword) { + return this.call('auth.admin.set_password', { + token: this.token, + new_password: newPassword + }, 'POST'); + } + + async resetAdminPassword() { + return this.call('auth.admin.reset_password', { + token: this.token + }, 'POST'); + } isLoggedIn() { if (!this.token) { @@ -238,7 +260,7 @@ class TianShanAPI { const parsed = expires ? parseInt(expires) : NaN; const expired = expires && Date.now() >= parsed; if (expired) { - this.logout(); // 清理过期的 token + this.clearAuth(); return false; } return true; diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index 5bf7863..0aacf13 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -128,6 +128,7 @@ class SubscriptionManager { document.addEventListener('DOMContentLoaded', () => { // 初始化认证 UI updateAuthUI(); + validateStoredSession(); // 仅当 localStorage 无有效语言偏好时,才从设备 system.language 同步(不覆盖用户已有选择) (async function syncLanguageFromDevice() { @@ -194,6 +195,26 @@ document.addEventListener('DOMContentLoaded', () => { // 认证 // ========================================================================= +window.addEventListener('authExpired', () => { + updateAuthUI(); + showLoginModal(); +}); + +async function validateStoredSession() { + if (!api.isLoggedIn()) return; + + try { + const status = await api.checkAuthStatus(); + if (!status || !status.valid) { + api.clearAuth(); + updateAuthUI(); + showLoginModal(); + } + } catch (error) { + console.debug('Stored session validation failed:', error); + } +} + function updateAuthUI() { const loginBtn = document.getElementById('login-btn'); const userName = document.getElementById('user-name'); @@ -1573,9 +1594,9 @@ function showFanAutoHelpModal() { modal.innerHTML = `