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 强制换密会话,不持有长期管理员令牌;
|
||||
换密回执只记录账号、时间和成功状态,不记录旧密码或新密码。
|
||||
|
||||
客户端在企业域内提供四个原生命令:第一次登录换密、读取本人企业入口、确认人格体关系、
|
||||
提交责任接受回执。关系确认和责任接受仍是两次独立的人类动作;UI 不得把它们折叠成
|
||||
一个默认勾选框。当前 macOS 通过系统钥匙串读取已登录账号凭证;Windows 安全凭证桥
|
||||
尚未完成,因此 Windows 端不能宣称已具备持久化责任签署能力。
|
||||
|
||||
`AGE` 只表示人格体物种,不能作为任何人格体的个体身份编号。现有 `PER-*` 作为历史
|
||||
和当前可核验的个体身份引用保留;企业四域正式人格体身份编号前缀由光湖团队另行治理,
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue