feat(enterprise): add first-login rotation and receipt bridge
This commit is contained in:
parent
a6cab287fb
commit
08f514b9d1
9 changed files with 322 additions and 4 deletions
|
|
@ -20,6 +20,13 @@ API 路由对客户端开放:
|
||||||
- 关系确认:由人类确认自己与人格体的认领关系;
|
- 关系确认:由人类确认自己与人格体的认领关系;
|
||||||
- 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受;
|
- 责任回执:独立记录对域责任的接受、拒绝、延期或修改后接受;
|
||||||
- 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。
|
- 仓库验证:登录凭证只透传给同机 Forgejo 验证,不写入数据库或日志。
|
||||||
|
- 首次换密:使用用户自己的一次性凭证进入 Forgejo 强制换密会话,不持有长期管理员令牌;
|
||||||
|
换密回执只记录账号、时间和成功状态,不记录旧密码或新密码。
|
||||||
|
|
||||||
|
客户端在企业域内提供四个原生命令:第一次登录换密、读取本人企业入口、确认人格体关系、
|
||||||
|
提交责任接受回执。关系确认和责任接受仍是两次独立的人类动作;UI 不得把它们折叠成
|
||||||
|
一个默认勾选框。当前 macOS 通过系统钥匙串读取已登录账号凭证;Windows 安全凭证桥
|
||||||
|
尚未完成,因此 Windows 端不能宣称已具备持久化责任签署能力。
|
||||||
|
|
||||||
`AGE` 只表示人格体物种,不能作为任何人格体的个体身份编号。现有 `PER-*` 作为历史
|
`AGE` 只表示人格体物种,不能作为任何人格体的个体身份编号。现有 `PER-*` 作为历史
|
||||||
和当前可核验的个体身份引用保留;企业四域正式人格体身份编号前缀由光湖团队另行治理,
|
和当前可核验的个体身份引用保留;企业四域正式人格体身份编号前缀由光湖团队另行治理,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ verified against the local Forgejo API and are never stored or logged.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import http.cookiejar
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
|
@ -35,6 +36,9 @@ REGISTRY_PATH = os.environ.get(
|
||||||
FORGEJO_USER_API = os.environ.get(
|
FORGEJO_USER_API = os.environ.get(
|
||||||
"GH_ENTERPRISE_FORGEJO_USER_API", "http://127.0.0.1:3341/api/v1/user"
|
"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", "")
|
RECEIPT_KEY = os.environ.get("GH_ENTERPRISE_RECEIPT_KEY", "")
|
||||||
MAX_BODY = 16_384
|
MAX_BODY = 16_384
|
||||||
USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
USERNAME = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
||||||
|
|
@ -102,6 +106,14 @@ def database() -> sqlite3.Connection:
|
||||||
receipt_hash TEXT NOT NULL,
|
receipt_hash TEXT NOT NULL,
|
||||||
receipt_signature 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 (
|
CREATE TABLE IF NOT EXISTS audit (
|
||||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
observed_at INTEGER NOT NULL,
|
observed_at INTEGER NOT NULL,
|
||||||
|
|
@ -152,6 +164,43 @@ def verify_forgejo(username: str, password: str) -> bool:
|
||||||
return False
|
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:
|
def signed_receipt(payload: dict) -> dict:
|
||||||
if len(RECEIPT_KEY) < 32:
|
if len(RECEIPT_KEY) < 32:
|
||||||
raise RuntimeError("receipt signing key unavailable")
|
raise RuntimeError("receipt signing key unavailable")
|
||||||
|
|
@ -255,6 +304,48 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
payload = self.body()
|
payload = self.body()
|
||||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
return self.respond(400, {"ok": False, "error": str(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)
|
authenticated = self.authenticated_human(registry, payload)
|
||||||
if not authenticated:
|
if not authenticated:
|
||||||
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
return self.respond(401, {"ok": False, "error": "enterprise account authentication failed"})
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,17 @@ class EnterpriseIdentityTests(unittest.TestCase):
|
||||||
tables = {row[0] for row in db.execute("select name from sqlite_master where type='table'")}
|
tables = {row[0] for row in db.execute("select name from sqlite_master where type='table'")}
|
||||||
self.assertIn("relationship_receipts", tables)
|
self.assertIn("relationship_receipts", tables)
|
||||||
self.assertIn("responsibility_receipts", tables)
|
self.assertIn("responsibility_receipts", tables)
|
||||||
|
self.assertIn("credential_rotation_receipts", tables)
|
||||||
db.close()
|
db.close()
|
||||||
finally:
|
finally:
|
||||||
service.DB_PATH = old
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,11 @@ location = /api/hololake/enterprise/me/entry {
|
||||||
proxy_read_timeout 15s;
|
proxy_read_timeout 15s;
|
||||||
client_max_body_size 16k;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ test('the public shell shows five domains and login is domain-routed', () => {
|
||||||
assert.match(frontend, /输入编号进入所属域/)
|
assert.match(frontend, /输入编号进入所属域/)
|
||||||
assert.match(login, /login_host_for_domain/)
|
assert.match(login, /login_host_for_domain/)
|
||||||
assert.match(login, /ENTERPRISE_LOGIN_HOST.*guanghu\.chat/)
|
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, /enterprise_resolve_url/)
|
||||||
assert.match(router, /verified_user_route/)
|
assert.match(router, /verified_user_route/)
|
||||||
assert.match(router, /FIFTH_DOMAIN.*MAIN_DOMAIN.*BRANCH_DOMAIN.*ZERO_DOMAIN.*ZERO_SENSE_DOMAIN/s)
|
assert.match(router, /FIFTH_DOMAIN.*MAIN_DOMAIN.*BRANCH_DOMAIN.*ZERO_DOMAIN.*ZERO_SENSE_DOMAIN/s)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ pub struct LoginReceipt {
|
||||||
pub domain: String,
|
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<std::path::PathBuf, String> {
|
fn session_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
Ok(app
|
Ok(app
|
||||||
.path()
|
.path()
|
||||||
|
|
@ -84,6 +91,20 @@ fn keychain_has(host: &str, username: &str) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn keychain_read(host: &str, username: &str) -> Result<String, String> {
|
||||||
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn keychain_remove(host: &str, username: &str) {
|
fn keychain_remove(host: &str, username: &str) {
|
||||||
let _ = Command::new("/usr/bin/security")
|
let _ = Command::new("/usr/bin/security")
|
||||||
|
|
@ -102,6 +123,11 @@ fn keychain_has(_host: &str, _username: &str) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn keychain_read(_host: &str, _username: &str) -> Result<String, String> {
|
||||||
|
Err("HOLOLAKE_PERSISTENT_CREDENTIAL_UNAVAILABLE".into())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
fn keychain_remove(_host: &str, _username: &str) {}
|
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<PasswordRotationReceipt, String> {
|
||||||
|
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<serde_json::Value, String> {
|
||||||
|
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<serde_json::Value, String> {
|
||||||
|
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<serde_json::Value, String> {
|
||||||
|
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<serde_json::Value, String> {
|
||||||
|
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 即拒;
|
/// 登录验证:基本认证打 Forgejo 用户接口,密码错=401 即拒;
|
||||||
/// 通过后凭证进钥匙串,会话(不含密码)落盘。
|
/// 通过后凭证进钥匙串,会话(不含密码)落盘。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ pub fn run() {
|
||||||
pncc_server_projection::query_jd_pncc_server_projection,
|
pncc_server_projection::query_jd_pncc_server_projection,
|
||||||
code_repo_login::check_code_repo_login,
|
code_repo_login::check_code_repo_login,
|
||||||
code_repo_login::perform_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,
|
code_repo_login::sign_out_code_repo_login,
|
||||||
user_pncc_channel::get_user_pncc_channel,
|
user_pncc_channel::get_user_pncc_channel,
|
||||||
user_pncc_channel::ensure_user_pncc_channel,
|
user_pncc_channel::ensure_user_pncc_channel,
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,9 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' |
|
||||||
if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。'
|
if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。'
|
||||||
if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。'
|
if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。'
|
||||||
if (value.includes('LOGIN_VERIFICATION_FAILED')) 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_LOGIN_NOT_PROVISIONED')) return '该域的企业服务器尚未接入,当前不能继续登录。'
|
||||||
if (value.includes('DOMAIN_ROUTE_REQUIRED') || value.includes('ZP_DOMAIN_ROUTE_REQUIRED')) return '编号尚未解析到已登记域,不能开始登录。'
|
if (value.includes('DOMAIN_ROUTE_REQUIRED') || value.includes('ZP_DOMAIN_ROUTE_REQUIRED')) return '编号尚未解析到已登记域,不能开始登录。'
|
||||||
if (value.includes('USER_PNCC_TRUSTED_SUBJECT_REQUIRED')) return '建立人格代码频道前,需要有效的用户编号与仓库账号登录。'
|
if (value.includes('USER_PNCC_TRUSTED_SUBJECT_REQUIRED')) return '建立人格代码频道前,需要有效的用户编号与仓库账号登录。'
|
||||||
|
|
@ -349,6 +352,9 @@ function HoloLakeApp() {
|
||||||
const [userPnccMessage, setUserPnccMessage] = useState('')
|
const [userPnccMessage, setUserPnccMessage] = useState('')
|
||||||
const [loginUsername, setLoginUsername] = useState('')
|
const [loginUsername, setLoginUsername] = useState('')
|
||||||
const [loginPassword, setLoginPassword] = useState('')
|
const [loginPassword, setLoginPassword] = useState('')
|
||||||
|
const [passwordChangeMode, setPasswordChangeMode] = useState(false)
|
||||||
|
const [newLoginPassword, setNewLoginPassword] = useState('')
|
||||||
|
const [confirmLoginPassword, setConfirmLoginPassword] = useState('')
|
||||||
const [loginBusy, setLoginBusy] = useState(false)
|
const [loginBusy, setLoginBusy] = useState(false)
|
||||||
const [loginRising, setLoginRising] = useState(false)
|
const [loginRising, setLoginRising] = useState(false)
|
||||||
const [loginMessage, setLoginMessage] = useState('')
|
const [loginMessage, setLoginMessage] = useState('')
|
||||||
|
|
@ -733,6 +739,28 @@ function HoloLakeApp() {
|
||||||
} catch (error) { setLoginMessage(humanError(error, 'login')) }
|
} catch (error) { setLoginMessage(humanError(error, 'login')) }
|
||||||
finally { setLoginBusy(false) }
|
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 () => {
|
const signOutRepo = async () => {
|
||||||
try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ }
|
try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ }
|
||||||
setPersonal(previewPersonal)
|
setPersonal(previewPersonal)
|
||||||
|
|
@ -1038,12 +1066,17 @@ function HoloLakeApp() {
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className={`gate-pod gate-pod-key${loginRising ? ' fade-out' : ''}`} role="dialog">
|
<section className={`gate-pod gate-pod-key${loginRising ? ' fade-out' : ''}`} role="dialog">
|
||||||
<form onSubmit={(event) => void performRepoLogin(event)}>
|
<form onSubmit={(event) => void (passwordChangeMode ? changeFirstLoginPassword(event) : performRepoLogin(event))}>
|
||||||
<input id="repo-login-username" aria-label="账号" autoFocus maxLength={40} value={loginUsername} placeholder="账号" onChange={(event) => setLoginUsername(event.target.value)}/>
|
<input id="repo-login-username" aria-label="账号" autoFocus maxLength={40} value={loginUsername} placeholder="账号" onChange={(event) => setLoginUsername(event.target.value)}/>
|
||||||
<input id="repo-login-password" aria-label="密码" type="password" maxLength={512} value={loginPassword} placeholder="密码" onChange={(event) => setLoginPassword(event.target.value)}/>
|
<input id="repo-login-password" aria-label={passwordChangeMode ? '一次性密码' : '密码'} type="password" maxLength={512} value={loginPassword} placeholder={passwordChangeMode ? '一次性密码' : '密码'} onChange={(event) => setLoginPassword(event.target.value)}/>
|
||||||
<button className="gate-submit" disabled={loginBusy || !loginUsername.trim() || !loginPassword}>{loginBusy ? '正在验证…' : '验证并进入'}</button>
|
{passwordChangeMode && <>
|
||||||
|
<input aria-label="新密码" type="password" minLength={14} maxLength={128} value={newLoginPassword} placeholder="设置新密码(至少 14 位)" onChange={(event) => setNewLoginPassword(event.target.value)}/>
|
||||||
|
<input aria-label="确认新密码" type="password" minLength={14} maxLength={128} value={confirmLoginPassword} placeholder="再次输入新密码" onChange={(event) => setConfirmLoginPassword(event.target.value)}/>
|
||||||
|
</>}
|
||||||
|
<button className="gate-submit" disabled={loginBusy || !loginUsername.trim() || !loginPassword || (passwordChangeMode && (!newLoginPassword || !confirmLoginPassword))}>{loginBusy ? '正在处理…' : passwordChangeMode ? '修改密码' : '验证并进入'}</button>
|
||||||
</form>
|
</form>
|
||||||
{loginMessage && <p className="gate-hint">{loginMessage}</p>}
|
{loginMessage && <p className="gate-hint">{loginMessage}</p>}
|
||||||
|
{zeroPoint?.resolvedDomain !== 'FIFTH_DOMAIN' && <button className="gate-back" type="button" onClick={() => { setPasswordChangeMode((value) => !value); setLoginMessage(''); setNewLoginPassword(''); setConfirmLoginPassword('') }}>{passwordChangeMode ? '返回正常登录' : '第一次使用?先修改一次性密码'}</button>}
|
||||||
<button className="gate-back" type="button" onClick={() => { setGateStage('number'); setGateMessage(''); setLoginMessage('') }}>返回编号验证</button>
|
<button className="gate-back" type="button" onClick={() => { setGateStage('number'); setGateMessage(''); setLoginMessage('') }}>返回编号验证</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"schema": "hololake.current-architecture/v1",
|
"schema": "hololake.current-architecture/v1",
|
||||||
"architecture_id": "HLP-CURRENT-ARCH-001",
|
"architecture_id": "HLP-CURRENT-ARCH-001",
|
||||||
"version": "2026-08-16.10",
|
"version": "2026-08-16.11",
|
||||||
"state": "CURRENT_CANONICAL",
|
"state": "CURRENT_CANONICAL",
|
||||||
"product": {
|
"product": {
|
||||||
"formal_name": "光湖语言系统 · 通用人工智能操作平台",
|
"formal_name": "光湖语言系统 · 通用人工智能操作平台",
|
||||||
|
|
@ -24,6 +24,9 @@
|
||||||
"enterprise_private_work_repositories": "FIVE_LIVE_SEPARATE_OWNER_BOUND_PRIVATE_REPOSITORIES",
|
"enterprise_private_work_repositories": "FIVE_LIVE_SEPARATE_OWNER_BOUND_PRIVATE_REPOSITORIES",
|
||||||
"zero_sense_dual_control": "FEIMAO_AND_JUZI_SEPARATE_PRIVATE_REPOSITORIES",
|
"zero_sense_dual_control": "FEIMAO_AND_JUZI_SEPARATE_PRIVATE_REPOSITORIES",
|
||||||
"relationship_confirmation_and_responsibility_acceptance": "SEPARATE_SIGNED_RECEIPT_STREAMS",
|
"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,
|
"age_is_persona_species_not_individual_number_namespace": true,
|
||||||
"enterprise_persona_identity_namespace": "PENDING_GUANGHU_TEAM_GOVERNANCE",
|
"enterprise_persona_identity_namespace": "PENDING_GUANGHU_TEAM_GOVERNANCE",
|
||||||
"ordinary_user_node_requires_full_guanghu_os_server": false,
|
"ordinary_user_node_requires_full_guanghu_os_server": false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue