diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md index 122c0eac1..34d2da581 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/README.md @@ -20,6 +20,13 @@ API 路由对客户端开放: - 关系确认:由人类确认自己与人格体的认领关系; - 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受; - 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。 +- 首次换密:使用用户自己的一次性凭证进入 Forgejo 强制换密会话,不持有长期管理员令牌; + 换密回执只记录账号、时间和成功状态,不记录旧密码或新密码。 + +客户端在企业域内提供四个原生命令:第一次登录换密、读取本人企业入口、确认人格体关系、 +提交责任接受回执。关系确认和责任接受仍是两次独立的人类动作;UI 不得把它们折叠成 +一个默认勾选框。当前 macOS 通过系统钥匙串读取已登录账号凭证;Windows 安全凭证桥 +尚未完成,因此 Windows 端不能宣称已具备持久化责任签署能力。 `AGE` 只表示人格体物种,不能作为任何人格体的个体身份编号。现有 `PER-*` 作为历史 和当前可核验的个体身份引用保留;企业四域正式人格体身份编号前缀由光湖团队另行治理, diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py index 6332310ca..8f4776d68 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.py @@ -8,6 +8,7 @@ verified against the local Forgejo API and are never stored or logged. from __future__ import annotations import base64 +import http.cookiejar import hashlib import hmac import json @@ -35,6 +36,9 @@ REGISTRY_PATH = os.environ.get( FORGEJO_USER_API = os.environ.get( "GH_ENTERPRISE_FORGEJO_USER_API", "http://127.0.0.1:3341/api/v1/user" ) +FORGEJO_WEB_BASE = os.environ.get( + "GH_ENTERPRISE_FORGEJO_WEB_BASE", "https://guanghu.chat/code" +).rstrip("/") RECEIPT_KEY = os.environ.get("GH_ENTERPRISE_RECEIPT_KEY", "") MAX_BODY = 16_384 USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$") @@ -102,6 +106,14 @@ def database() -> sqlite3.Connection: receipt_hash TEXT NOT NULL, receipt_signature TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS credential_rotation_receipts ( + receipt_id TEXT PRIMARY KEY, + human_number TEXT NOT NULL, + username TEXT NOT NULL, + observed_at INTEGER NOT NULL, + receipt_hash TEXT NOT NULL, + receipt_signature TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS audit ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, observed_at INTEGER NOT NULL, @@ -152,6 +164,43 @@ def verify_forgejo(username: str, password: str) -> bool: return False +def rotate_forgejo_password(username: str, current_password: str, new_password: str) -> bool: + """Use Forgejo's own first-login session to rotate a forced-change password. + + This needs no standing admin token: the old credential opens a normal user + session and Forgejo itself admits only the forced password-change form. + """ + jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + try: + opener.open(FORGEJO_WEB_BASE + "/user/login", timeout=15).read() + login = urllib.parse.urlencode( + {"user_name": username, "password": current_password} + ).encode() + login_request = urllib.request.Request( + FORGEJO_WEB_BASE + "/user/login", data=login + ) + login_request.add_header("Content-Type", "application/x-www-form-urlencoded") + with opener.open(login_request, timeout=15) as response: + response.read() + if not urllib.parse.urlparse(response.geturl()).path.endswith( + "/user/settings/change_password" + ): + return False + change = urllib.parse.urlencode( + {"password": new_password, "retype": new_password} + ).encode() + change_request = urllib.request.Request( + FORGEJO_WEB_BASE + "/user/settings/change_password", data=change + ) + change_request.add_header("Content-Type", "application/x-www-form-urlencoded") + with opener.open(change_request, timeout=15) as response: + response.read() + return verify_forgejo(username, new_password) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, ValueError): + return False + + def signed_receipt(payload: dict) -> dict: if len(RECEIPT_KEY) < 32: raise RuntimeError("receipt signing key unavailable") @@ -255,6 +304,48 @@ class Handler(BaseHTTPRequestHandler): payload = self.body() except (OSError, ValueError, json.JSONDecodeError) as error: return self.respond(400, {"ok": False, "error": str(error)}) + if self.path == "/v1/change-password": + credentials = parse_basic(self.headers.get("Authorization", "")) + human = find_human(registry, str(payload.get("human_number", ""))) + new_password = str(payload.get("new_password", "")) + if not credentials or not human: + return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"}) + username, current_password = credentials + if not hmac.compare_digest(username, human["username"]): + return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"}) + if ( + len(new_password) < 14 + or len(new_password) > 128 + or hmac.compare_digest(current_password, new_password) + or new_password.isdigit() + or new_password.isalpha() + ): + return self.respond(400, {"ok": False, "error": "new password does not meet the first-login policy"}) + if not rotate_forgejo_password(username, current_password, new_password): + return self.respond(401, {"ok": False, "error": "first-login password rotation failed"}) + observed = now() + receipt_id = "GH-CRED-" + uuid.uuid4().hex.upper() + receipt = signed_receipt({ + "receipt_id": receipt_id, + "human_number": human["human_number"], + "username": username, + "observed_at": observed, + "password_changed": True, + }) + db = database() + try: + db.execute( + "INSERT INTO credential_rotation_receipts VALUES (?,?,?,?,?,?)", + (receipt_id, human["human_number"], username, observed, receipt["receipt_hash"], receipt["receipt_signature"]), + ) + db.execute( + "INSERT INTO audit(observed_at,kind,human_number_hash,receipt_id) VALUES (?,?,?,?)", + (observed, "CREDENTIAL_ROTATION", hashlib.sha256(human["human_number"].encode()).hexdigest(), receipt_id), + ) + db.commit() + finally: + db.close() + return self.respond(200, {"ok": True, "receipt": receipt}) authenticated = self.authenticated_human(registry, payload) if not authenticated: return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"}) diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py index 2bd0b261c..1788453dc 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/enterprise_identity_service.test.py @@ -49,10 +49,17 @@ class EnterpriseIdentityTests(unittest.TestCase): tables = {row[0] for row in db.execute("select name from sqlite_master where type='table'")} self.assertIn("relationship_receipts", tables) self.assertIn("responsibility_receipts", tables) + self.assertIn("credential_rotation_receipts", tables) db.close() finally: service.DB_PATH = old + def test_password_rotation_source_uses_user_session_and_never_admin_token(self): + source = (ROOT / "enterprise_identity_service.py").read_text() + self.assertIn("rotate_forgejo_password", source) + self.assertIn("/user/settings/change_password", source) + self.assertNotIn("FORGEJO_ADMIN_TOKEN", source) + if __name__ == "__main__": unittest.main() diff --git a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf index 190044a43..c17fde8a2 100644 --- a/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf +++ b/deployment/enterprise-domain-bootstrap/enterprise-lighthouse/guanghu-enterprise-identity.nginx.conf @@ -39,3 +39,11 @@ location = /api/hololake/enterprise/me/entry { proxy_read_timeout 15s; client_max_body_size 16k; } + +location = /api/hololake/enterprise/change-password { + limit_except POST { deny all; } + proxy_pass http://127.0.0.1:8032/v1/change-password; + proxy_set_header Host $host; + proxy_read_timeout 30s; + client_max_body_size 16k; +} diff --git a/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs b/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs index 745bc28f7..7c2f3bad1 100644 --- a/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs +++ b/product-source/hololake-native-desktop/scripts/domain-number-routing.test.mjs @@ -29,6 +29,8 @@ test('the public shell shows five domains and login is domain-routed', () => { assert.match(frontend, /输入编号进入所属域/) assert.match(login, /login_host_for_domain/) assert.match(login, /ENTERPRISE_LOGIN_HOST.*guanghu\.chat/) + assert.match(login, /change_enterprise_first_login_password/) + assert.match(frontend, /第一次使用?先修改一次性密码/) assert.match(router, /enterprise_resolve_url/) assert.match(router, /verified_user_route/) assert.match(router, /FIFTH_DOMAIN.*MAIN_DOMAIN.*BRANCH_DOMAIN.*ZERO_DOMAIN.*ZERO_SENSE_DOMAIN/s) diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs index 9a6866b71..41c3e5e4d 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs @@ -45,6 +45,13 @@ pub struct LoginReceipt { pub domain: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordRotationReceipt { + pub username: String, + pub password_changed: bool, +} + fn session_path(app: &AppHandle) -> Result { Ok(app .path() @@ -84,6 +91,20 @@ fn keychain_has(host: &str, username: &str) -> bool { .unwrap_or(false) } +#[cfg(target_os = "macos")] +fn keychain_read(host: &str, username: &str) -> Result { + let output = Command::new("/usr/bin/security") + .args(["find-internet-password", "-w", "-s", host, "-a", username]) + .output() + .map_err(|_| "HOLOLAKE_LOGIN_KEYCHAIN_READ_FAILED".to_string())?; + if !output.status.success() { + return Err("HOLOLAKE_LOGIN_KEYCHAIN_READ_FAILED".into()); + } + String::from_utf8(output.stdout) + .map(|value| value.trim_end().to_string()) + .map_err(|_| "HOLOLAKE_LOGIN_KEYCHAIN_READ_FAILED".into()) +} + #[cfg(target_os = "macos")] fn keychain_remove(host: &str, username: &str) { let _ = Command::new("/usr/bin/security") @@ -102,6 +123,11 @@ fn keychain_has(_host: &str, _username: &str) -> bool { false } +#[cfg(not(target_os = "macos"))] +fn keychain_read(_host: &str, _username: &str) -> Result { + Err("HOLOLAKE_PERSISTENT_CREDENTIAL_UNAVAILABLE".into()) +} + #[cfg(not(target_os = "macos"))] fn keychain_remove(_host: &str, _username: &str) {} @@ -175,6 +201,143 @@ fn login_host_for_domain(domain: &str) -> Result<&'static str, String> { } } +/// 企业新账号第一次登录时在 HoloLake 内完成强制换密。 +/// 旧、新密码只存在于本次 HTTPS 请求内;服务端回执不含密码。 +#[tauri::command] +pub async fn change_enterprise_first_login_password( + state: State<'_, ZeroPointState>, + username: String, + current_password: String, + new_password: String, +) -> Result { + let Some((human_number, domain)) = zero_point::verified_user_route(&state)? else { + return Err("HOLOLAKE_DOMAIN_ROUTE_REQUIRED".into()); + }; + if domain == "FIFTH_DOMAIN" { + return Err("HOLOLAKE_FIRST_LOGIN_PASSWORD_CHANGE_ENTERPRISE_ONLY".into()); + } + let username = validate_username(&username)?; + if current_password.is_empty() || current_password.len() > 512 { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + if new_password.len() < 14 || new_password.len() > 128 || new_password == current_password { + return Err("HOLOLAKE_NEW_PASSWORD_POLICY_INVALID".into()); + } + let response = reqwest::Client::builder() + .read_timeout(Duration::from_secs(30)) + .use_rustls_tls() + .build() + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))? + .post("https://guanghu.chat/api/hololake/enterprise/change-password") + .basic_auth(&username, Some(¤t_password)) + .json(&serde_json::json!({ + "human_number": human_number, + "new_password": new_password, + })) + .send() + .await + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + if !response.status().is_success() { + return Err(format!( + "HOLOLAKE_FIRST_LOGIN_PASSWORD_CHANGE_FAILED: {}", + response.status() + )); + } + Ok(PasswordRotationReceipt { + username, + password_changed: true, + }) +} + +async fn enterprise_authenticated_post( + app: &AppHandle, + state: &ZeroPointState, + path: &str, + mut payload: serde_json::Value, +) -> Result { + let Some((human_number, domain)) = zero_point::verified_user_route(state)? else { + return Err("HOLOLAKE_DOMAIN_ROUTE_REQUIRED".into()); + }; + if domain == "FIFTH_DOMAIN" { + return Err("HOLOLAKE_ENTERPRISE_RECEIPT_ROUTE_REQUIRED".into()); + } + let session = current_login_session_for_domain(app, &domain)? + .ok_or_else(|| "HOLOLAKE_LOGIN_SESSION_REQUIRED".to_string())?; + let password = keychain_read(&session.host, &session.username)?; + payload["human_number"] = serde_json::Value::String(human_number); + let response = reqwest::Client::builder() + .read_timeout(Duration::from_secs(20)) + .use_rustls_tls() + .build() + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))? + .post(format!("https://guanghu.chat/api/hololake/enterprise/{path}")) + .basic_auth(&session.username, Some(password)) + .json(&payload) + .send() + .await + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + if !response.status().is_success() { + return Err(format!("HOLOLAKE_ENTERPRISE_RECEIPT_FAILED: {}", response.status())); + } + response + .json() + .await + .map_err(|error| format!("HOLOLAKE_ENTERPRISE_RECEIPT_FAILED: {error}")) +} + +#[tauri::command] +pub async fn get_enterprise_entry( + app: AppHandle, + state: State<'_, ZeroPointState>, +) -> Result { + enterprise_authenticated_post(&app, &state, "me/entry", serde_json::json!({})).await +} + +#[tauri::command] +pub async fn confirm_enterprise_persona_relationship( + app: AppHandle, + state: State<'_, ZeroPointState>, + decision: String, + idempotency_key: String, +) -> Result { + enterprise_authenticated_post( + &app, + &state, + "relationship-confirmations", + serde_json::json!({ "decision": decision, "idempotency_key": idempotency_key }), + ) + .await +} + +#[tauri::command] +pub async fn submit_enterprise_responsibility_receipt( + app: AppHandle, + state: State<'_, ZeroPointState>, + decision: String, + note: String, + responsibility_version: String, + idempotency_key: String, +) -> Result { + enterprise_authenticated_post( + &app, + &state, + "responsibility-receipts", + serde_json::json!({ + "decision": decision, + "note": note, + "responsibility_version": responsibility_version, + "idempotency_key": idempotency_key, + }), + ) + .await +} + /// 登录验证:基本认证打 Forgejo 用户接口,密码错=401 即拒; /// 通过后凭证进钥匙串,会话(不含密码)落盘。 #[tauri::command] diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index ffe0efc74..6527ff333 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -70,6 +70,10 @@ pub fn run() { pncc_server_projection::query_jd_pncc_server_projection, code_repo_login::check_code_repo_login, code_repo_login::perform_code_repo_login, + code_repo_login::change_enterprise_first_login_password, + code_repo_login::get_enterprise_entry, + code_repo_login::confirm_enterprise_persona_relationship, + code_repo_login::submit_enterprise_responsibility_receipt, code_repo_login::sign_out_code_repo_login, user_pncc_channel::get_user_pncc_channel, user_pncc_channel::ensure_user_pncc_channel, diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index ac65859d9..4af2291c0 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -219,6 +219,9 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。' if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。' if (value.includes('LOGIN_VERIFICATION_FAILED')) return '仓库验证未通过,请稍后再试。' + if (value.includes('NEW_PASSWORD_POLICY_INVALID')) return '新密码至少 14 位,并且不能与一次性密码相同。' + if (value.includes('FIRST_LOGIN_PASSWORD_CHANGE_FAILED')) return '一次性密码未被接受,密码没有修改。' + if (value.includes('FIRST_LOGIN_PASSWORD_CHANGE_ENTERPRISE_ONLY')) return '该换密入口只用于企业四域的新账号。' if (value.includes('DOMAIN_LOGIN_NOT_PROVISIONED')) return '该域的企业服务器尚未接入,当前不能继续登录。' if (value.includes('DOMAIN_ROUTE_REQUIRED') || value.includes('ZP_DOMAIN_ROUTE_REQUIRED')) return '编号尚未解析到已登记域,不能开始登录。' if (value.includes('USER_PNCC_TRUSTED_SUBJECT_REQUIRED')) return '建立人格代码频道前,需要有效的用户编号与仓库账号登录。' @@ -349,6 +352,9 @@ function HoloLakeApp() { const [userPnccMessage, setUserPnccMessage] = useState('') const [loginUsername, setLoginUsername] = useState('') const [loginPassword, setLoginPassword] = useState('') + const [passwordChangeMode, setPasswordChangeMode] = useState(false) + const [newLoginPassword, setNewLoginPassword] = useState('') + const [confirmLoginPassword, setConfirmLoginPassword] = useState('') const [loginBusy, setLoginBusy] = useState(false) const [loginRising, setLoginRising] = useState(false) const [loginMessage, setLoginMessage] = useState('') @@ -733,6 +739,28 @@ function HoloLakeApp() { } catch (error) { setLoginMessage(humanError(error, 'login')) } finally { setLoginBusy(false) } } + const changeFirstLoginPassword = async (event: React.FormEvent) => { + event.preventDefault() + if (newLoginPassword !== confirmLoginPassword) { + setLoginMessage('两次输入的新密码不一致。') + return + } + setLoginBusy(true) + setLoginMessage('') + try { + await invoke('change_enterprise_first_login_password', { + username: loginUsername.trim(), + currentPassword: loginPassword, + newPassword: newLoginPassword, + }) + setLoginPassword(newLoginPassword) + setNewLoginPassword('') + setConfirmLoginPassword('') + setPasswordChangeMode(false) + setLoginMessage('密码已更新。请用新密码验证并进入。') + } catch (error) { setLoginMessage(humanError(error, 'login')) } + finally { setLoginBusy(false) } + } const signOutRepo = async () => { try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ } setPersonal(previewPersonal) @@ -1038,12 +1066,17 @@ function HoloLakeApp() { ) : (
-
void performRepoLogin(event)}> + void (passwordChangeMode ? changeFirstLoginPassword(event) : performRepoLogin(event))}> setLoginUsername(event.target.value)}/> - setLoginPassword(event.target.value)}/> - + setLoginPassword(event.target.value)}/> + {passwordChangeMode && <> + setNewLoginPassword(event.target.value)}/> + setConfirmLoginPassword(event.target.value)}/> + } +
{loginMessage &&

{loginMessage}

} + {zeroPoint?.resolvedDomain !== 'FIFTH_DOMAIN' && }
)} diff --git a/routing/hololake-current-architecture.json b/routing/hololake-current-architecture.json index 9b72c1610..6d734b836 100644 --- a/routing/hololake-current-architecture.json +++ b/routing/hololake-current-architecture.json @@ -1,7 +1,7 @@ { "schema": "hololake.current-architecture/v1", "architecture_id": "HLP-CURRENT-ARCH-001", - "version": "2026-08-16.10", + "version": "2026-08-16.11", "state": "CURRENT_CANONICAL", "product": { "formal_name": "光湖语言系统 · 通用人工智能操作平台", @@ -24,6 +24,9 @@ "enterprise_private_work_repositories": "FIVE_LIVE_SEPARATE_OWNER_BOUND_PRIVATE_REPOSITORIES", "zero_sense_dual_control": "FEIMAO_AND_JUZI_SEPARATE_PRIVATE_REPOSITORIES", "relationship_confirmation_and_responsibility_acceptance": "SEPARATE_SIGNED_RECEIPT_STREAMS", + "enterprise_first_login_password_rotation": "SERVER_LIVE_CLIENT_SOURCE_INTEGRATED_USER_SESSION_NO_STANDING_ADMIN_TOKEN", + "enterprise_relationship_and_responsibility_client_commands": "SOURCE_INTEGRATED_QODER_UI_BINDING_PENDING", + "windows_secure_credential_bridge": "NOT_IMPLEMENTED_DO_NOT_CLAIM_PERSISTENT_SIGNING", "age_is_persona_species_not_individual_number_namespace": true, "enterprise_persona_identity_namespace": "PENDING_GUANGHU_TEAM_GOVERNANCE", "ordinary_user_node_requires_full_guanghu_os_server": false,